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
ctrl_freak/__init__.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""ctrl-freak: Extensible Genetic Algorithm Framework.
|
|
2
|
+
|
|
3
|
+
A pure numpy implementation of genetic algorithms including NSGA-II for
|
|
4
|
+
multi-objective optimization and standard GA for single-objective optimization.
|
|
5
|
+
|
|
6
|
+
Example (multi-objective with NSGA-II):
|
|
7
|
+
>>> from ctrl_freak import nsga2, Population
|
|
8
|
+
>>> import numpy as np
|
|
9
|
+
>>> def init(rng): return rng.uniform(0, 1, size=3)
|
|
10
|
+
>>> def evaluate(x): return np.array([x.sum(), (1 - x).sum()])
|
|
11
|
+
>>> def crossover(p1, p2): return (p1 + p2) / 2
|
|
12
|
+
>>> def mutate(x): return np.clip(x + 0.01, 0, 1)
|
|
13
|
+
>>> result = nsga2(init, evaluate, crossover, mutate, pop_size=10, n_generations=5, seed=42)
|
|
14
|
+
>>> len(result.population)
|
|
15
|
+
10
|
|
16
|
+
|
|
17
|
+
Example (single-objective with GA):
|
|
18
|
+
>>> from ctrl_freak import ga
|
|
19
|
+
>>> import numpy as np
|
|
20
|
+
>>> def init(rng): return rng.uniform(0, 1, size=3)
|
|
21
|
+
>>> def evaluate(x): return x.sum() # Single objective
|
|
22
|
+
>>> def crossover(p1, p2): return (p1 + p2) / 2
|
|
23
|
+
>>> def mutate(x): return np.clip(x + 0.01, 0, 1)
|
|
24
|
+
>>> result = ga(init, evaluate, crossover, mutate, pop_size=10, n_generations=5, seed=42)
|
|
25
|
+
>>> len(result.population)
|
|
26
|
+
10
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from importlib.metadata import version
|
|
30
|
+
|
|
31
|
+
from ctrl_freak.algorithms import ga, nsga2
|
|
32
|
+
from ctrl_freak.operators import (
|
|
33
|
+
create_offspring,
|
|
34
|
+
lift,
|
|
35
|
+
lift_parallel,
|
|
36
|
+
polynomial_mutation,
|
|
37
|
+
sbx_crossover,
|
|
38
|
+
select_parents,
|
|
39
|
+
)
|
|
40
|
+
from ctrl_freak.population import IndividualView, Population
|
|
41
|
+
from ctrl_freak.primitives import (
|
|
42
|
+
crowding_distance,
|
|
43
|
+
dominates,
|
|
44
|
+
dominates_matrix,
|
|
45
|
+
non_dominated_sort,
|
|
46
|
+
)
|
|
47
|
+
from ctrl_freak.protocols import ParentSelector, SurvivorSelector
|
|
48
|
+
from ctrl_freak.registry import (
|
|
49
|
+
SelectionRegistry,
|
|
50
|
+
SurvivalRegistry,
|
|
51
|
+
list_selections,
|
|
52
|
+
list_survivals,
|
|
53
|
+
)
|
|
54
|
+
from ctrl_freak.results import GAResult, NSGA2Result
|
|
55
|
+
from ctrl_freak.selection import crowded_tournament, fitness_tournament, roulette_wheel
|
|
56
|
+
from ctrl_freak.survival import elitist_survival, nsga2_survival, truncation_survival
|
|
57
|
+
|
|
58
|
+
__version__ = version("ctrl-freak")
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
# Algorithms
|
|
62
|
+
"nsga2",
|
|
63
|
+
"ga",
|
|
64
|
+
# Selection strategies
|
|
65
|
+
"crowded_tournament",
|
|
66
|
+
"fitness_tournament",
|
|
67
|
+
"roulette_wheel",
|
|
68
|
+
# Survival strategies
|
|
69
|
+
"nsga2_survival",
|
|
70
|
+
"truncation_survival",
|
|
71
|
+
"elitist_survival",
|
|
72
|
+
# Genetic operators
|
|
73
|
+
"lift",
|
|
74
|
+
"lift_parallel",
|
|
75
|
+
"select_parents",
|
|
76
|
+
"create_offspring",
|
|
77
|
+
"sbx_crossover",
|
|
78
|
+
"polynomial_mutation",
|
|
79
|
+
# Primitives
|
|
80
|
+
"dominates",
|
|
81
|
+
"dominates_matrix",
|
|
82
|
+
"non_dominated_sort",
|
|
83
|
+
"crowding_distance",
|
|
84
|
+
# Registry system
|
|
85
|
+
"SelectionRegistry",
|
|
86
|
+
"SurvivalRegistry",
|
|
87
|
+
"list_selections",
|
|
88
|
+
"list_survivals",
|
|
89
|
+
# Protocols
|
|
90
|
+
"ParentSelector",
|
|
91
|
+
"SurvivorSelector",
|
|
92
|
+
# Data structures
|
|
93
|
+
"Population",
|
|
94
|
+
"IndividualView",
|
|
95
|
+
# Result types
|
|
96
|
+
"NSGA2Result",
|
|
97
|
+
"GAResult",
|
|
98
|
+
# Version
|
|
99
|
+
"__version__",
|
|
100
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Evolutionary algorithm implementations.
|
|
2
|
+
|
|
3
|
+
This module provides pluggable algorithm implementations with configurable
|
|
4
|
+
selection and survival strategies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ctrl_freak.algorithms.ga import ga
|
|
8
|
+
from ctrl_freak.algorithms.nsga2 import nsga2
|
|
9
|
+
|
|
10
|
+
__all__ = ["ga", "nsga2"]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Standard genetic algorithm with pluggable strategies.
|
|
2
|
+
|
|
3
|
+
Examples
|
|
4
|
+
--------
|
|
5
|
+
>>> import numpy as np
|
|
6
|
+
>>> from ctrl_freak.algorithms.ga import ga
|
|
7
|
+
>>> def init(rng):
|
|
8
|
+
... return rng.uniform(0.0, 1.0, size=3)
|
|
9
|
+
>>> def evaluate(x):
|
|
10
|
+
... return float(np.sum(x**2))
|
|
11
|
+
>>> result = ga(
|
|
12
|
+
... init=init,
|
|
13
|
+
... evaluate=evaluate,
|
|
14
|
+
... crossover=lambda p1, p2: (p1 + p2) / 2,
|
|
15
|
+
... mutate=lambda x: np.clip(x + 0.01, 0.0, 1.0),
|
|
16
|
+
... pop_size=10,
|
|
17
|
+
... n_generations=2,
|
|
18
|
+
... seed=42,
|
|
19
|
+
... )
|
|
20
|
+
>>> result.population.x.shape
|
|
21
|
+
(10, 3)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
# Import selection and survival modules to trigger strategy registration
|
|
29
|
+
import ctrl_freak.selection # noqa: F401
|
|
30
|
+
import ctrl_freak.survival # noqa: F401
|
|
31
|
+
from ctrl_freak.operators import lift, lift_parallel
|
|
32
|
+
from ctrl_freak.population import Population
|
|
33
|
+
from ctrl_freak.protocols import ParentSelector, SurvivorSelector
|
|
34
|
+
from ctrl_freak.registry import SelectionRegistry, SurvivalRegistry
|
|
35
|
+
from ctrl_freak.results import GAResult
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ga(
|
|
39
|
+
init: Callable[[np.random.Generator], np.ndarray],
|
|
40
|
+
evaluate: Callable[[np.ndarray], float],
|
|
41
|
+
crossover: Callable[[np.ndarray, np.ndarray], np.ndarray],
|
|
42
|
+
mutate: Callable[[np.ndarray], np.ndarray],
|
|
43
|
+
pop_size: int,
|
|
44
|
+
n_generations: int,
|
|
45
|
+
seed: int | None = None,
|
|
46
|
+
callback: Callable[[GAResult, int], bool] | None = None,
|
|
47
|
+
select: str | ParentSelector = "tournament",
|
|
48
|
+
survive: str | SurvivorSelector = "elitist",
|
|
49
|
+
n_workers: int = 1,
|
|
50
|
+
) -> GAResult:
|
|
51
|
+
"""Run a single-objective genetic algorithm.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
init
|
|
56
|
+
Callable that initializes one individual from a random generator.
|
|
57
|
+
evaluate
|
|
58
|
+
Callable that evaluates one individual and returns a scalar objective.
|
|
59
|
+
Lower objective values are better.
|
|
60
|
+
crossover
|
|
61
|
+
Callable that crosses two parents to produce one child.
|
|
62
|
+
mutate
|
|
63
|
+
Callable that mutates one individual.
|
|
64
|
+
pop_size
|
|
65
|
+
Population size. Must be positive and even.
|
|
66
|
+
n_generations
|
|
67
|
+
Number of generations to run.
|
|
68
|
+
seed
|
|
69
|
+
Master random seed. If ``None``, system entropy is used.
|
|
70
|
+
callback
|
|
71
|
+
Optional callback called before each generation. Return ``True`` to stop.
|
|
72
|
+
select
|
|
73
|
+
Parent selection strategy name or callable.
|
|
74
|
+
survive
|
|
75
|
+
Survivor selection strategy name or callable.
|
|
76
|
+
n_workers
|
|
77
|
+
Number of workers for objective evaluation. Parallel evaluation is
|
|
78
|
+
deterministic only when ``evaluate`` is pure.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
GAResult
|
|
83
|
+
Final population, fitness vector, best index, generation count, and
|
|
84
|
+
evaluation count.
|
|
85
|
+
|
|
86
|
+
Raises
|
|
87
|
+
------
|
|
88
|
+
ValueError
|
|
89
|
+
If size, generation, or worker arguments are invalid.
|
|
90
|
+
KeyError
|
|
91
|
+
If a named strategy is not registered.
|
|
92
|
+
|
|
93
|
+
Examples
|
|
94
|
+
--------
|
|
95
|
+
>>> import numpy as np
|
|
96
|
+
>>> from ctrl_freak.algorithms.ga import ga
|
|
97
|
+
>>> def init(rng):
|
|
98
|
+
... return rng.uniform(0.0, 1.0, size=2)
|
|
99
|
+
>>> def evaluate(x):
|
|
100
|
+
... return float(np.sum(x**2))
|
|
101
|
+
>>> result = ga(
|
|
102
|
+
... init=init,
|
|
103
|
+
... evaluate=evaluate,
|
|
104
|
+
... crossover=lambda p1, p2: (p1 + p2) / 2,
|
|
105
|
+
... mutate=lambda x: x.copy(),
|
|
106
|
+
... pop_size=10,
|
|
107
|
+
... n_generations=2,
|
|
108
|
+
... seed=1,
|
|
109
|
+
... )
|
|
110
|
+
>>> result.generations
|
|
111
|
+
2
|
|
112
|
+
"""
|
|
113
|
+
# Validate inputs
|
|
114
|
+
if pop_size <= 0:
|
|
115
|
+
raise ValueError(f"pop_size must be positive, got {pop_size}")
|
|
116
|
+
if pop_size % 2 != 0:
|
|
117
|
+
raise ValueError(f"pop_size must be even for proper parent pairing, got {pop_size}")
|
|
118
|
+
if n_generations < 0:
|
|
119
|
+
raise ValueError(f"n_generations must be non-negative, got {n_generations}")
|
|
120
|
+
if n_workers < 1 and n_workers != -1:
|
|
121
|
+
raise ValueError(f"n_workers must be positive or -1 (all cores), got {n_workers}")
|
|
122
|
+
|
|
123
|
+
# Resolve selection and survival strategies
|
|
124
|
+
parent_selector = SelectionRegistry.get(select) if isinstance(select, str) else select
|
|
125
|
+
survivor_selector = SurvivalRegistry.get(survive) if isinstance(survive, str) else survive
|
|
126
|
+
|
|
127
|
+
# Derive independent per-phase RNG streams from the single master seed so one seed
|
|
128
|
+
# reproduces init + parent selection + crossover + mutation bit-identically.
|
|
129
|
+
# Child order is the reproducibility contract: [init, select, crossover, mutate]. Never reorder.
|
|
130
|
+
init_ss, select_ss, crossover_ss, mutate_ss = np.random.SeedSequence(seed).spawn(4)
|
|
131
|
+
rng = np.random.default_rng(init_ss)
|
|
132
|
+
select_rng = np.random.default_rng(select_ss)
|
|
133
|
+
set_crossover_rng = getattr(crossover, "set_rng", None)
|
|
134
|
+
if callable(set_crossover_rng):
|
|
135
|
+
set_crossover_rng(np.random.default_rng(crossover_ss))
|
|
136
|
+
set_mutate_rng = getattr(mutate, "set_rng", None)
|
|
137
|
+
if callable(set_mutate_rng):
|
|
138
|
+
set_mutate_rng(np.random.default_rng(mutate_ss))
|
|
139
|
+
|
|
140
|
+
def evaluate_array(x: np.ndarray) -> np.ndarray:
|
|
141
|
+
return np.asarray(evaluate(x))
|
|
142
|
+
|
|
143
|
+
# Shared lifted evaluation path. Parallel determinism assumes evaluate is pure.
|
|
144
|
+
lifted_evaluate = lift_parallel(evaluate_array, n_workers) if n_workers != 1 else lift(evaluate_array)
|
|
145
|
+
|
|
146
|
+
# Initialize population
|
|
147
|
+
init_x = np.stack([init(rng) for _ in range(pop_size)])
|
|
148
|
+
init_obj = lifted_evaluate(init_x)
|
|
149
|
+
if init_obj.ndim == 1:
|
|
150
|
+
init_obj = init_obj.reshape(-1, 1)
|
|
151
|
+
|
|
152
|
+
pop = Population(x=init_x, objectives=init_obj)
|
|
153
|
+
|
|
154
|
+
# Compute initial fitness state
|
|
155
|
+
# Extract fitness from single-objective population
|
|
156
|
+
assert pop.objectives is not None # Guaranteed by initialization above
|
|
157
|
+
state: dict[str, np.ndarray] = {"fitness": pop.objectives[:, 0].copy()}
|
|
158
|
+
|
|
159
|
+
# Track evaluations: initial population
|
|
160
|
+
total_evaluations = pop_size
|
|
161
|
+
generations_completed = 0
|
|
162
|
+
|
|
163
|
+
# Main evolutionary loop
|
|
164
|
+
for gen in range(n_generations):
|
|
165
|
+
# Extract fitness from state for current GAResult
|
|
166
|
+
fitness = state["fitness"]
|
|
167
|
+
best_idx = int(np.argmin(fitness))
|
|
168
|
+
|
|
169
|
+
# Create current GAResult for callback
|
|
170
|
+
current_result = GAResult(
|
|
171
|
+
population=pop,
|
|
172
|
+
fitness=fitness,
|
|
173
|
+
best_idx=best_idx,
|
|
174
|
+
generations=generations_completed,
|
|
175
|
+
evaluations=total_evaluations,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Check callback for early stopping
|
|
179
|
+
if callback is not None and callback(current_result, gen):
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
# Select parents using current state
|
|
183
|
+
parent_indices = parent_selector(pop, pop_size, select_rng, **state)
|
|
184
|
+
|
|
185
|
+
# Create offspring via crossover and mutation
|
|
186
|
+
offspring_x = np.empty_like(pop.x)
|
|
187
|
+
for i in range(0, pop_size, 2):
|
|
188
|
+
p1_idx, p2_idx = parent_indices[i], parent_indices[i + 1]
|
|
189
|
+
child1 = crossover(pop.x[p1_idx], pop.x[p2_idx])
|
|
190
|
+
child2 = crossover(pop.x[p2_idx], pop.x[p1_idx])
|
|
191
|
+
offspring_x[i] = mutate(child1)
|
|
192
|
+
offspring_x[i + 1] = mutate(child2)
|
|
193
|
+
|
|
194
|
+
offspring_obj = lifted_evaluate(offspring_x)
|
|
195
|
+
if offspring_obj.ndim == 1:
|
|
196
|
+
offspring_obj = offspring_obj.reshape(-1, 1)
|
|
197
|
+
total_evaluations += pop_size
|
|
198
|
+
|
|
199
|
+
# Combine parent and offspring populations
|
|
200
|
+
assert pop.objectives is not None # Guaranteed by initialization
|
|
201
|
+
combined = Population(
|
|
202
|
+
x=np.concatenate([pop.x, offspring_x]),
|
|
203
|
+
objectives=np.concatenate([pop.objectives, offspring_obj]),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
survivor_indices, state = survivor_selector(
|
|
207
|
+
combined,
|
|
208
|
+
pop_size,
|
|
209
|
+
parent_size=pop_size,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Update population
|
|
213
|
+
assert combined.objectives is not None # Guaranteed by Population construction above
|
|
214
|
+
pop = Population(
|
|
215
|
+
x=combined.x[survivor_indices],
|
|
216
|
+
objectives=combined.objectives[survivor_indices],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
generations_completed += 1
|
|
220
|
+
|
|
221
|
+
# Return final result
|
|
222
|
+
fitness = state["fitness"]
|
|
223
|
+
best_idx = int(np.argmin(fitness))
|
|
224
|
+
|
|
225
|
+
final_result = GAResult(
|
|
226
|
+
population=pop,
|
|
227
|
+
fitness=fitness,
|
|
228
|
+
best_idx=best_idx,
|
|
229
|
+
generations=generations_completed,
|
|
230
|
+
evaluations=total_evaluations,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return final_result
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""NSGA-II algorithm with pluggable strategies.
|
|
2
|
+
|
|
3
|
+
Examples
|
|
4
|
+
--------
|
|
5
|
+
>>> import numpy as np
|
|
6
|
+
>>> from ctrl_freak.algorithms.nsga2 import nsga2
|
|
7
|
+
>>> def init(rng):
|
|
8
|
+
... return rng.uniform(0.0, 1.0, size=3)
|
|
9
|
+
>>> def evaluate(x):
|
|
10
|
+
... return np.array([np.sum(x), np.sum(1.0 - x)])
|
|
11
|
+
>>> result = nsga2(
|
|
12
|
+
... init=init,
|
|
13
|
+
... evaluate=evaluate,
|
|
14
|
+
... crossover=lambda p1, p2: (p1 + p2) / 2,
|
|
15
|
+
... mutate=lambda x: np.clip(x + 0.01, 0.0, 1.0),
|
|
16
|
+
... pop_size=10,
|
|
17
|
+
... n_generations=2,
|
|
18
|
+
... seed=42,
|
|
19
|
+
... )
|
|
20
|
+
>>> result.population.x.shape
|
|
21
|
+
(10, 3)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
# Import selection and survival modules to trigger strategy registration
|
|
29
|
+
import ctrl_freak.selection # noqa: F401
|
|
30
|
+
import ctrl_freak.survival # noqa: F401
|
|
31
|
+
from ctrl_freak.operators import lift, lift_parallel
|
|
32
|
+
from ctrl_freak.population import Population
|
|
33
|
+
from ctrl_freak.protocols import ParentSelector, SurvivorSelector
|
|
34
|
+
from ctrl_freak.registry import SelectionRegistry, SurvivalRegistry
|
|
35
|
+
from ctrl_freak.results import NSGA2Result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def nsga2(
|
|
39
|
+
init: Callable[[np.random.Generator], np.ndarray],
|
|
40
|
+
evaluate: Callable[[np.ndarray], np.ndarray],
|
|
41
|
+
crossover: Callable[[np.ndarray, np.ndarray], np.ndarray],
|
|
42
|
+
mutate: Callable[[np.ndarray], np.ndarray],
|
|
43
|
+
pop_size: int,
|
|
44
|
+
n_generations: int,
|
|
45
|
+
seed: int | None = None,
|
|
46
|
+
callback: Callable[[NSGA2Result, int], bool] | None = None,
|
|
47
|
+
select: str | ParentSelector = "crowded",
|
|
48
|
+
survive: str | SurvivorSelector = "nsga2",
|
|
49
|
+
n_workers: int = 1,
|
|
50
|
+
) -> NSGA2Result:
|
|
51
|
+
"""Run NSGA-II multi-objective optimization.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
init
|
|
56
|
+
Callable that initializes one individual from a random generator.
|
|
57
|
+
evaluate
|
|
58
|
+
Callable that evaluates one individual and returns objective values.
|
|
59
|
+
crossover
|
|
60
|
+
Callable that crosses two parents to produce one child.
|
|
61
|
+
mutate
|
|
62
|
+
Callable that mutates one individual.
|
|
63
|
+
pop_size
|
|
64
|
+
Population size. Must be positive and even.
|
|
65
|
+
n_generations
|
|
66
|
+
Number of generations to run.
|
|
67
|
+
seed
|
|
68
|
+
Master random seed. If ``None``, system entropy is used.
|
|
69
|
+
callback
|
|
70
|
+
Optional callback called before each generation. Return ``True`` to stop.
|
|
71
|
+
select
|
|
72
|
+
Parent selection strategy name or callable.
|
|
73
|
+
survive
|
|
74
|
+
Survivor selection strategy name or callable.
|
|
75
|
+
n_workers
|
|
76
|
+
Number of workers for objective evaluation. Parallel evaluation is
|
|
77
|
+
deterministic only when ``evaluate`` is pure.
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
NSGA2Result
|
|
82
|
+
Final population, ranks, crowding distances, generation count, and
|
|
83
|
+
evaluation count.
|
|
84
|
+
|
|
85
|
+
Raises
|
|
86
|
+
------
|
|
87
|
+
ValueError
|
|
88
|
+
If size, generation, or worker arguments are invalid.
|
|
89
|
+
KeyError
|
|
90
|
+
If a named strategy is not registered.
|
|
91
|
+
|
|
92
|
+
Examples
|
|
93
|
+
--------
|
|
94
|
+
>>> import numpy as np
|
|
95
|
+
>>> from ctrl_freak.algorithms.nsga2 import nsga2
|
|
96
|
+
>>> def init(rng):
|
|
97
|
+
... return rng.uniform(0.0, 1.0, size=2)
|
|
98
|
+
>>> def evaluate(x):
|
|
99
|
+
... return np.array([np.sum(x), np.sum(1.0 - x)])
|
|
100
|
+
>>> result = nsga2(
|
|
101
|
+
... init=init,
|
|
102
|
+
... evaluate=evaluate,
|
|
103
|
+
... crossover=lambda p1, p2: (p1 + p2) / 2,
|
|
104
|
+
... mutate=lambda x: x.copy(),
|
|
105
|
+
... pop_size=10,
|
|
106
|
+
... n_generations=2,
|
|
107
|
+
... seed=1,
|
|
108
|
+
... )
|
|
109
|
+
>>> result.generations
|
|
110
|
+
2
|
|
111
|
+
"""
|
|
112
|
+
# Validate inputs
|
|
113
|
+
if pop_size <= 0:
|
|
114
|
+
raise ValueError(f"pop_size must be positive, got {pop_size}")
|
|
115
|
+
if pop_size % 2 != 0:
|
|
116
|
+
raise ValueError(f"pop_size must be even for proper parent pairing, got {pop_size}")
|
|
117
|
+
if n_generations < 0:
|
|
118
|
+
raise ValueError(f"n_generations must be non-negative, got {n_generations}")
|
|
119
|
+
if n_workers < 1 and n_workers != -1:
|
|
120
|
+
raise ValueError(f"n_workers must be positive or -1 (all cores), got {n_workers}")
|
|
121
|
+
|
|
122
|
+
# Resolve selection and survival strategies
|
|
123
|
+
parent_selector = SelectionRegistry.get(select) if isinstance(select, str) else select
|
|
124
|
+
|
|
125
|
+
survivor_selector = SurvivalRegistry.get(survive) if isinstance(survive, str) else survive
|
|
126
|
+
|
|
127
|
+
# Create evaluator (parallel or sequential)
|
|
128
|
+
lifted_evaluate = lift_parallel(evaluate, n_workers) if n_workers != 1 else lift(evaluate)
|
|
129
|
+
|
|
130
|
+
# Derive independent per-phase RNG streams from the single master seed so one seed
|
|
131
|
+
# reproduces init + parent selection + crossover + mutation bit-identically.
|
|
132
|
+
# Child order is the reproducibility contract: [init, select, crossover, mutate]. Never reorder.
|
|
133
|
+
init_ss, select_ss, crossover_ss, mutate_ss = np.random.SeedSequence(seed).spawn(4)
|
|
134
|
+
rng = np.random.default_rng(init_ss)
|
|
135
|
+
select_rng = np.random.default_rng(select_ss)
|
|
136
|
+
set_crossover_rng = getattr(crossover, "set_rng", None)
|
|
137
|
+
if callable(set_crossover_rng):
|
|
138
|
+
set_crossover_rng(np.random.default_rng(crossover_ss))
|
|
139
|
+
set_mutate_rng = getattr(mutate, "set_rng", None)
|
|
140
|
+
if callable(set_mutate_rng):
|
|
141
|
+
set_mutate_rng(np.random.default_rng(mutate_ss))
|
|
142
|
+
|
|
143
|
+
# Initialize population
|
|
144
|
+
init_x = np.stack([init(rng) for _ in range(pop_size)])
|
|
145
|
+
init_obj = lifted_evaluate(init_x)
|
|
146
|
+
pop = Population(x=init_x, objectives=init_obj)
|
|
147
|
+
|
|
148
|
+
# Compute initial state via survival strategy
|
|
149
|
+
# This gives us initial rank and crowding distance
|
|
150
|
+
_, state = survivor_selector(pop, pop_size)
|
|
151
|
+
|
|
152
|
+
# Track evaluations: initial population
|
|
153
|
+
total_evaluations = pop_size
|
|
154
|
+
generations_completed = 0
|
|
155
|
+
|
|
156
|
+
# Main evolutionary loop
|
|
157
|
+
for gen in range(n_generations):
|
|
158
|
+
# Create current NSGA2Result for callback
|
|
159
|
+
current_result = NSGA2Result(
|
|
160
|
+
population=pop,
|
|
161
|
+
rank=state["rank"],
|
|
162
|
+
crowding_distance=state["crowding_distance"],
|
|
163
|
+
generations=generations_completed,
|
|
164
|
+
evaluations=total_evaluations,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Check callback for early stopping
|
|
168
|
+
if callback is not None and callback(current_result, gen):
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
# Select parents using current state
|
|
172
|
+
parent_indices = parent_selector(pop, pop_size, select_rng, **state)
|
|
173
|
+
|
|
174
|
+
# Create offspring via crossover and mutation
|
|
175
|
+
offspring_x = np.empty_like(pop.x)
|
|
176
|
+
for i in range(0, pop_size, 2):
|
|
177
|
+
p1_idx, p2_idx = parent_indices[i], parent_indices[i + 1]
|
|
178
|
+
child1 = crossover(pop.x[p1_idx], pop.x[p2_idx])
|
|
179
|
+
child2 = crossover(pop.x[p2_idx], pop.x[p1_idx])
|
|
180
|
+
offspring_x[i] = mutate(child1)
|
|
181
|
+
offspring_x[i + 1] = mutate(child2)
|
|
182
|
+
|
|
183
|
+
# Evaluate offspring
|
|
184
|
+
offspring_obj = lifted_evaluate(offspring_x)
|
|
185
|
+
total_evaluations += pop_size
|
|
186
|
+
|
|
187
|
+
# Combine parent and offspring populations
|
|
188
|
+
assert pop.objectives is not None # Guaranteed by initialization
|
|
189
|
+
combined = Population(
|
|
190
|
+
x=np.concatenate([pop.x, offspring_x]),
|
|
191
|
+
objectives=np.concatenate([pop.objectives, offspring_obj]),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Select survivors for next generation
|
|
195
|
+
survivor_indices, state = survivor_selector(combined, pop_size)
|
|
196
|
+
|
|
197
|
+
# Update population
|
|
198
|
+
assert combined.objectives is not None # Guaranteed by Population construction above
|
|
199
|
+
pop = Population(
|
|
200
|
+
x=combined.x[survivor_indices],
|
|
201
|
+
objectives=combined.objectives[survivor_indices],
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
generations_completed += 1
|
|
205
|
+
|
|
206
|
+
# Return final result
|
|
207
|
+
final_result = NSGA2Result(
|
|
208
|
+
population=pop,
|
|
209
|
+
rank=state["rank"],
|
|
210
|
+
crowding_distance=state["crowding_distance"],
|
|
211
|
+
generations=generations_completed,
|
|
212
|
+
evaluations=total_evaluations,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return final_result
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Genetic operators for evolutionary algorithms.
|
|
2
|
+
|
|
3
|
+
This module provides:
|
|
4
|
+
- lift: decorator to lift per-individual functions to population level
|
|
5
|
+
- sbx_crossover: Simulated Binary Crossover factory
|
|
6
|
+
- polynomial_mutation: Polynomial mutation factory
|
|
7
|
+
- select_parents: Binary tournament selection
|
|
8
|
+
- create_offspring: Create offspring via selection, crossover, and mutation
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ctrl_freak.operators.base import lift, lift_parallel
|
|
12
|
+
from ctrl_freak.operators.selection import create_offspring, select_parents
|
|
13
|
+
from ctrl_freak.operators.standard import polynomial_mutation, sbx_crossover
|
|
14
|
+
|
|
15
|
+
__all__ = ["lift", "lift_parallel", "sbx_crossover", "polynomial_mutation", "select_parents", "create_offspring"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Base genetic-operator adapters."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def lift(fn: Callable[[np.ndarray], np.ndarray]) -> Callable[[np.ndarray], np.ndarray]:
|
|
9
|
+
"""Lift a per-individual function to work on a population.
|
|
10
|
+
|
|
11
|
+
This utility allows users to write simple per-individual functions
|
|
12
|
+
while the framework handles batching/vectorization.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
fn : collections.abc.Callable
|
|
17
|
+
Function that operates on one individual with signature
|
|
18
|
+
``(n_vars,) -> (n_out,)``.
|
|
19
|
+
|
|
20
|
+
Returns
|
|
21
|
+
-------
|
|
22
|
+
collections.abc.Callable
|
|
23
|
+
Function that operates on a population with signature
|
|
24
|
+
``(n, n_vars) -> (n, n_out)``.
|
|
25
|
+
|
|
26
|
+
Examples
|
|
27
|
+
--------
|
|
28
|
+
>>> def evaluate_one(x: np.ndarray) -> np.ndarray:
|
|
29
|
+
... return np.array([x.sum(), x.prod()])
|
|
30
|
+
>>> evaluate = lift(evaluate_one)
|
|
31
|
+
>>> pop_x = np.array([[1.0, 2.0], [3.0, 4.0]])
|
|
32
|
+
>>> evaluate(pop_x)
|
|
33
|
+
array([[ 3., 2.],
|
|
34
|
+
[ 7., 12.]])
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def lifted(x: np.ndarray) -> np.ndarray:
|
|
38
|
+
return np.stack([fn(x[i]) for i in range(x.shape[0])])
|
|
39
|
+
|
|
40
|
+
return lifted
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def lift_parallel(fn: Callable[[np.ndarray], np.ndarray], n_workers: int) -> Callable[[np.ndarray], np.ndarray]:
|
|
44
|
+
"""Lift a per-individual function to work on a population with parallel execution.
|
|
45
|
+
|
|
46
|
+
This utility allows users to write simple per-individual functions
|
|
47
|
+
while the framework handles batching/vectorization with parallel workers.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
fn : collections.abc.Callable
|
|
52
|
+
Function that operates on one individual with signature
|
|
53
|
+
``(n_vars,) -> (n_out,)``. It must be picklable for multiprocessing.
|
|
54
|
+
n_workers : int
|
|
55
|
+
Number of parallel workers. Use ``-1`` for all CPU cores.
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
collections.abc.Callable
|
|
60
|
+
Function that operates on a population in parallel with signature
|
|
61
|
+
``(n, n_vars) -> (n, n_out)``.
|
|
62
|
+
|
|
63
|
+
Examples
|
|
64
|
+
--------
|
|
65
|
+
>>> def evaluate_one(x: np.ndarray) -> np.ndarray:
|
|
66
|
+
... return np.array([x.sum(), x.prod()])
|
|
67
|
+
>>> evaluate = lift_parallel(evaluate_one, n_workers=4)
|
|
68
|
+
>>> pop_x = np.array([[1.0, 2.0], [3.0, 4.0]])
|
|
69
|
+
>>> evaluate(pop_x)
|
|
70
|
+
array([[ 3., 2.],
|
|
71
|
+
[ 7., 12.]])
|
|
72
|
+
"""
|
|
73
|
+
from joblib import Parallel, delayed
|
|
74
|
+
|
|
75
|
+
def lifted(x: np.ndarray) -> np.ndarray:
|
|
76
|
+
results: list[np.ndarray] = Parallel(n_jobs=n_workers)( # type: ignore[assignment]
|
|
77
|
+
delayed(fn)(x[i]) for i in range(x.shape[0])
|
|
78
|
+
)
|
|
79
|
+
return np.stack(results)
|
|
80
|
+
|
|
81
|
+
return lifted
|