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/registry.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Registry system for selection and survival strategies.
|
|
2
|
+
|
|
3
|
+
This module provides a registry pattern for managing selection and survival
|
|
4
|
+
strategies in evolutionary algorithms. Instead of hardcoding strategy
|
|
5
|
+
implementations, users can register factories that create configured selectors
|
|
6
|
+
and retrieve them by name.
|
|
7
|
+
|
|
8
|
+
The registry pattern enables:
|
|
9
|
+
- **Pluggable strategies**: Swap selection methods without code changes
|
|
10
|
+
- **Configuration-driven experiments**: Select strategies by string name from config files
|
|
11
|
+
- **Discoverability**: List all available strategies programmatically
|
|
12
|
+
- **Factory pattern**: Register functions that create configured selectors
|
|
13
|
+
|
|
14
|
+
There are two independent registries:
|
|
15
|
+
1. **SelectionRegistry**: For parent selection strategies (ParentSelector protocol)
|
|
16
|
+
2. **SurvivalRegistry**: For survivor selection strategies (SurvivorSelector protocol)
|
|
17
|
+
|
|
18
|
+
Examples
|
|
19
|
+
--------
|
|
20
|
+
Strategies are registered as factories and retrieved by name::
|
|
21
|
+
|
|
22
|
+
from ctrl_freak.registry import SelectionRegistry, list_selections
|
|
23
|
+
|
|
24
|
+
def tournament_factory(size=2):
|
|
25
|
+
def tournament_selector(pop, n_parents, rng, **kwargs):
|
|
26
|
+
...
|
|
27
|
+
return tournament_selector
|
|
28
|
+
|
|
29
|
+
SelectionRegistry.register("tournament", tournament_factory)
|
|
30
|
+
selector = SelectionRegistry.get("tournament", size=3)
|
|
31
|
+
available = list_selections()
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
|
|
36
|
+
from ctrl_freak.protocols import ParentSelector, SurvivorSelector
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SelectionRegistry:
|
|
40
|
+
"""Registry for parent selection strategies.
|
|
41
|
+
|
|
42
|
+
This class provides a class-level registry for parent selection strategy
|
|
43
|
+
factories. Strategies are registered by name and can be retrieved with
|
|
44
|
+
custom configuration parameters.
|
|
45
|
+
|
|
46
|
+
The registry stores factory functions that accept keyword arguments and
|
|
47
|
+
return ParentSelector callables. This enables flexible configuration at
|
|
48
|
+
retrieval time.
|
|
49
|
+
|
|
50
|
+
Attributes
|
|
51
|
+
----------
|
|
52
|
+
_registry : dict[str, Callable[..., ParentSelector]]
|
|
53
|
+
Mapping from strategy names to factory functions.
|
|
54
|
+
|
|
55
|
+
Examples
|
|
56
|
+
--------
|
|
57
|
+
A strategy factory returns a callable that satisfies ``ParentSelector``::
|
|
58
|
+
|
|
59
|
+
def tournament_factory(size=2):
|
|
60
|
+
def selector(pop, n_parents, rng, **kwargs):
|
|
61
|
+
...
|
|
62
|
+
return selector
|
|
63
|
+
|
|
64
|
+
SelectionRegistry.register("tournament", tournament_factory)
|
|
65
|
+
selector = SelectionRegistry.get("tournament", size=5)
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
_registry: dict[str, Callable[..., ParentSelector]] = {}
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def register(cls, name: str, factory: Callable[..., ParentSelector]) -> None:
|
|
72
|
+
"""Register a parent selection strategy factory.
|
|
73
|
+
|
|
74
|
+
The factory is a callable that accepts keyword arguments and returns
|
|
75
|
+
a ParentSelector. This enables strategies to be configured at retrieval
|
|
76
|
+
time with custom parameters.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
name : str
|
|
81
|
+
Unique name for the strategy. Existing names are overwritten.
|
|
82
|
+
factory : Callable[..., ParentSelector]
|
|
83
|
+
Callable that returns a parent selector.
|
|
84
|
+
|
|
85
|
+
Examples
|
|
86
|
+
--------
|
|
87
|
+
>>> from ctrl_freak.registry import SelectionRegistry
|
|
88
|
+
>>> SelectionRegistry.register(
|
|
89
|
+
... "__doc_random__",
|
|
90
|
+
... lambda: lambda pop, n_parents, rng, **kw: rng.choice(len(pop), n_parents),
|
|
91
|
+
... )
|
|
92
|
+
>>> "__doc_random__" in SelectionRegistry.list()
|
|
93
|
+
True
|
|
94
|
+
"""
|
|
95
|
+
cls._registry[name] = factory
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def get(cls, name: str, **kwargs) -> ParentSelector:
|
|
99
|
+
"""Get a configured parent selector by name.
|
|
100
|
+
|
|
101
|
+
Retrieves the factory for the given strategy name and calls it with
|
|
102
|
+
the provided keyword arguments to create a configured selector.
|
|
103
|
+
|
|
104
|
+
Parameters
|
|
105
|
+
----------
|
|
106
|
+
name : str
|
|
107
|
+
Name of the registered strategy.
|
|
108
|
+
**kwargs
|
|
109
|
+
Configuration parameters passed to the factory function.
|
|
110
|
+
|
|
111
|
+
Returns
|
|
112
|
+
-------
|
|
113
|
+
ParentSelector
|
|
114
|
+
A configured parent selector callable.
|
|
115
|
+
|
|
116
|
+
Raises
|
|
117
|
+
------
|
|
118
|
+
KeyError
|
|
119
|
+
If the strategy name is not registered.
|
|
120
|
+
|
|
121
|
+
Examples
|
|
122
|
+
--------
|
|
123
|
+
>>> from ctrl_freak.registry import SelectionRegistry
|
|
124
|
+
>>> SelectionRegistry.register("__doc_empty__", lambda: lambda pop, n, rng, **kw: [])
|
|
125
|
+
>>> selector = SelectionRegistry.get("__doc_empty__")
|
|
126
|
+
>>> callable(selector)
|
|
127
|
+
True
|
|
128
|
+
"""
|
|
129
|
+
if name not in cls._registry:
|
|
130
|
+
available = ", ".join(sorted(cls._registry.keys())) or "none"
|
|
131
|
+
raise KeyError(f"Selection strategy '{name}' not found. Available strategies: {available}")
|
|
132
|
+
factory = cls._registry[name]
|
|
133
|
+
return factory(**kwargs)
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def list(cls) -> list[str]:
|
|
137
|
+
"""Return list of registered strategy names.
|
|
138
|
+
|
|
139
|
+
Returns
|
|
140
|
+
-------
|
|
141
|
+
list[str]
|
|
142
|
+
Sorted list of all registered parent selection strategy names.
|
|
143
|
+
|
|
144
|
+
Examples
|
|
145
|
+
--------
|
|
146
|
+
>>> from ctrl_freak.registry import SelectionRegistry
|
|
147
|
+
>>> isinstance(SelectionRegistry.list(), list)
|
|
148
|
+
True
|
|
149
|
+
"""
|
|
150
|
+
return sorted(cls._registry.keys())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class SurvivalRegistry:
|
|
154
|
+
"""Registry for survivor selection strategies.
|
|
155
|
+
|
|
156
|
+
This class provides a class-level registry for survivor selection strategy
|
|
157
|
+
factories. Strategies are registered by name and can be retrieved with
|
|
158
|
+
custom configuration parameters.
|
|
159
|
+
|
|
160
|
+
The registry stores factory functions that accept keyword arguments and
|
|
161
|
+
return SurvivorSelector callables. This enables flexible configuration at
|
|
162
|
+
retrieval time.
|
|
163
|
+
|
|
164
|
+
Attributes
|
|
165
|
+
----------
|
|
166
|
+
_registry : dict[str, Callable[..., SurvivorSelector]]
|
|
167
|
+
Mapping from strategy names to factory functions.
|
|
168
|
+
|
|
169
|
+
Examples
|
|
170
|
+
--------
|
|
171
|
+
A strategy factory returns a callable that satisfies ``SurvivorSelector``::
|
|
172
|
+
|
|
173
|
+
def nsga2_factory(preserve_diversity=True):
|
|
174
|
+
def selector(pop, n_survivors, **kwargs):
|
|
175
|
+
...
|
|
176
|
+
return selector
|
|
177
|
+
|
|
178
|
+
SurvivalRegistry.register("nsga2", nsga2_factory)
|
|
179
|
+
selector = SurvivalRegistry.get("nsga2", preserve_diversity=False)
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
_registry: dict[str, Callable[..., SurvivorSelector]] = {}
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def register(cls, name: str, factory: Callable[..., SurvivorSelector]) -> None:
|
|
186
|
+
"""Register a survivor selection strategy factory.
|
|
187
|
+
|
|
188
|
+
The factory is a callable that accepts keyword arguments and returns
|
|
189
|
+
a SurvivorSelector. This enables strategies to be configured at retrieval
|
|
190
|
+
time with custom parameters.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
name : str
|
|
195
|
+
Unique name for the strategy. Existing names are overwritten.
|
|
196
|
+
factory : Callable[..., SurvivorSelector]
|
|
197
|
+
Callable that returns a survivor selector.
|
|
198
|
+
|
|
199
|
+
Examples
|
|
200
|
+
--------
|
|
201
|
+
>>> import numpy as np
|
|
202
|
+
>>> from ctrl_freak.registry import SurvivalRegistry
|
|
203
|
+
>>> SurvivalRegistry.register(
|
|
204
|
+
... "__doc_truncation__",
|
|
205
|
+
... lambda: lambda pop, n, **kw: (np.arange(n), {}),
|
|
206
|
+
... )
|
|
207
|
+
>>> "__doc_truncation__" in SurvivalRegistry.list()
|
|
208
|
+
True
|
|
209
|
+
"""
|
|
210
|
+
cls._registry[name] = factory
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def get(cls, name: str, **kwargs) -> SurvivorSelector:
|
|
214
|
+
"""Get a configured survivor selector by name.
|
|
215
|
+
|
|
216
|
+
Retrieves the factory for the given strategy name and calls it with
|
|
217
|
+
the provided keyword arguments to create a configured selector.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
name : str
|
|
222
|
+
Name of the registered strategy.
|
|
223
|
+
**kwargs
|
|
224
|
+
Configuration parameters passed to the factory function.
|
|
225
|
+
|
|
226
|
+
Returns
|
|
227
|
+
-------
|
|
228
|
+
SurvivorSelector
|
|
229
|
+
A configured survivor selector callable.
|
|
230
|
+
|
|
231
|
+
Raises
|
|
232
|
+
------
|
|
233
|
+
KeyError
|
|
234
|
+
If the strategy name is not registered.
|
|
235
|
+
|
|
236
|
+
Examples
|
|
237
|
+
--------
|
|
238
|
+
>>> from ctrl_freak.registry import SurvivalRegistry
|
|
239
|
+
>>> SurvivalRegistry.register("__doc_survivor__", lambda: lambda pop, n, **kw: ([], {}))
|
|
240
|
+
>>> selector = SurvivalRegistry.get("__doc_survivor__")
|
|
241
|
+
>>> callable(selector)
|
|
242
|
+
True
|
|
243
|
+
"""
|
|
244
|
+
if name not in cls._registry:
|
|
245
|
+
available = ", ".join(sorted(cls._registry.keys())) or "none"
|
|
246
|
+
raise KeyError(f"Survival strategy '{name}' not found. Available strategies: {available}")
|
|
247
|
+
factory = cls._registry[name]
|
|
248
|
+
return factory(**kwargs)
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def list(cls) -> list[str]:
|
|
252
|
+
"""Return list of registered strategy names.
|
|
253
|
+
|
|
254
|
+
Returns
|
|
255
|
+
-------
|
|
256
|
+
list[str]
|
|
257
|
+
Sorted list of all registered survivor selection strategy names.
|
|
258
|
+
|
|
259
|
+
Examples
|
|
260
|
+
--------
|
|
261
|
+
>>> from ctrl_freak.registry import SurvivalRegistry
|
|
262
|
+
>>> isinstance(SurvivalRegistry.list(), list)
|
|
263
|
+
True
|
|
264
|
+
"""
|
|
265
|
+
return sorted(cls._registry.keys())
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def list_selections() -> list[str]:
|
|
269
|
+
"""List all registered parent selection strategies.
|
|
270
|
+
|
|
271
|
+
Convenience function that returns SelectionRegistry.list().
|
|
272
|
+
|
|
273
|
+
Returns
|
|
274
|
+
-------
|
|
275
|
+
list[str]
|
|
276
|
+
Sorted list of all registered parent selection strategy names.
|
|
277
|
+
|
|
278
|
+
Examples
|
|
279
|
+
--------
|
|
280
|
+
>>> from ctrl_freak.registry import list_selections
|
|
281
|
+
>>> isinstance(list_selections(), list)
|
|
282
|
+
True
|
|
283
|
+
"""
|
|
284
|
+
return SelectionRegistry.list()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def list_survivals() -> list[str]:
|
|
288
|
+
"""List all registered survivor selection strategies.
|
|
289
|
+
|
|
290
|
+
Convenience function that returns SurvivalRegistry.list().
|
|
291
|
+
|
|
292
|
+
Returns
|
|
293
|
+
-------
|
|
294
|
+
list[str]
|
|
295
|
+
Sorted list of all registered survivor selection strategy names.
|
|
296
|
+
|
|
297
|
+
Examples
|
|
298
|
+
--------
|
|
299
|
+
>>> from ctrl_freak.registry import list_survivals
|
|
300
|
+
>>> isinstance(list_survivals(), list)
|
|
301
|
+
True
|
|
302
|
+
"""
|
|
303
|
+
return SurvivalRegistry.list()
|
ctrl_freak/results.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Algorithm-specific result types for genetic algorithms.
|
|
2
|
+
|
|
3
|
+
This module provides result dataclasses that encapsulate algorithm-specific
|
|
4
|
+
metadata along with the final population:
|
|
5
|
+
|
|
6
|
+
- NSGA2Result: Results from NSGA-II multi-objective optimization
|
|
7
|
+
- GAResult: Results from single-objective genetic algorithms
|
|
8
|
+
|
|
9
|
+
Both classes are immutable (frozen dataclasses) to enforce functional style.
|
|
10
|
+
All numpy arrays are copied on construction to ensure immutability.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from ctrl_freak.population import Population
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class NSGA2Result:
|
|
22
|
+
"""Results from NSGA-II multi-objective optimization algorithm.
|
|
23
|
+
|
|
24
|
+
This class encapsulates the final population along with algorithm-specific
|
|
25
|
+
metadata like Pareto ranks and crowding distances. All arrays are copied
|
|
26
|
+
on construction to ensure immutability.
|
|
27
|
+
|
|
28
|
+
Attributes
|
|
29
|
+
----------
|
|
30
|
+
population : Population
|
|
31
|
+
The final population after optimization.
|
|
32
|
+
rank : numpy.ndarray
|
|
33
|
+
Pareto rank for each individual. Rank 0 indicates individuals on the
|
|
34
|
+
Pareto front.
|
|
35
|
+
crowding_distance : numpy.ndarray
|
|
36
|
+
Crowding distance for each individual.
|
|
37
|
+
generations : int
|
|
38
|
+
Number of generations completed during optimization.
|
|
39
|
+
evaluations : int
|
|
40
|
+
Total number of objective function evaluations performed.
|
|
41
|
+
|
|
42
|
+
Examples
|
|
43
|
+
--------
|
|
44
|
+
>>> import numpy as np
|
|
45
|
+
>>> from ctrl_freak.population import Population
|
|
46
|
+
>>> from ctrl_freak.results import NSGA2Result
|
|
47
|
+
>>> x = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
|
|
48
|
+
>>> obj = np.array([[0.5, 0.5], [0.3, 0.7], [0.4, 0.6]])
|
|
49
|
+
>>> pop = Population(x=x, objectives=obj)
|
|
50
|
+
>>> result = NSGA2Result(
|
|
51
|
+
... population=pop,
|
|
52
|
+
... rank=np.array([0, 0, 1]),
|
|
53
|
+
... crowding_distance=np.array([np.inf, np.inf, 0.5]),
|
|
54
|
+
... generations=100,
|
|
55
|
+
... evaluations=5000,
|
|
56
|
+
... )
|
|
57
|
+
>>> len(result.pareto_front)
|
|
58
|
+
2
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
population: Population
|
|
62
|
+
rank: np.ndarray
|
|
63
|
+
crowding_distance: np.ndarray
|
|
64
|
+
generations: int
|
|
65
|
+
evaluations: int
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
"""Validate shapes and copy arrays for immutability.
|
|
69
|
+
|
|
70
|
+
Raises
|
|
71
|
+
------
|
|
72
|
+
TypeError
|
|
73
|
+
If rank or crowding_distance are not numpy arrays.
|
|
74
|
+
ValueError
|
|
75
|
+
If array shapes are inconsistent.
|
|
76
|
+
"""
|
|
77
|
+
n = len(self.population)
|
|
78
|
+
|
|
79
|
+
# Validate rank
|
|
80
|
+
if not isinstance(self.rank, np.ndarray):
|
|
81
|
+
raise TypeError(f"rank must be a numpy array, got {type(self.rank).__name__}")
|
|
82
|
+
if self.rank.ndim != 1:
|
|
83
|
+
raise ValueError(f"rank must be 1D, got shape {self.rank.shape}")
|
|
84
|
+
if self.rank.shape[0] != n:
|
|
85
|
+
raise ValueError(f"rank has {self.rank.shape[0]} elements, expected {n} to match population size")
|
|
86
|
+
|
|
87
|
+
# Validate crowding_distance
|
|
88
|
+
if not isinstance(self.crowding_distance, np.ndarray):
|
|
89
|
+
raise TypeError(f"crowding_distance must be a numpy array, got {type(self.crowding_distance).__name__}")
|
|
90
|
+
if self.crowding_distance.ndim != 1:
|
|
91
|
+
raise ValueError(f"crowding_distance must be 1D, got shape {self.crowding_distance.shape}")
|
|
92
|
+
if self.crowding_distance.shape[0] != n:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"crowding_distance has {self.crowding_distance.shape[0]} elements, expected {n} to match population size"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Copy arrays for immutability (use object.__setattr__ for frozen dataclass)
|
|
98
|
+
object.__setattr__(self, "rank", self.rank.copy())
|
|
99
|
+
object.__setattr__(self, "crowding_distance", self.crowding_distance.copy())
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def pareto_front(self) -> Population:
|
|
103
|
+
"""Extract the Pareto front (rank-0 individuals) as a new Population.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
Population
|
|
108
|
+
A new Population containing only individuals with rank 0.
|
|
109
|
+
|
|
110
|
+
Examples
|
|
111
|
+
--------
|
|
112
|
+
>>> import numpy as np
|
|
113
|
+
>>> from ctrl_freak.population import Population
|
|
114
|
+
>>> from ctrl_freak.results import NSGA2Result
|
|
115
|
+
>>> pop = Population(
|
|
116
|
+
... x=np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]),
|
|
117
|
+
... objectives=np.array([[0.5, 0.5], [0.3, 0.7], [0.4, 0.6]]),
|
|
118
|
+
... )
|
|
119
|
+
>>> result = NSGA2Result(
|
|
120
|
+
... population=pop,
|
|
121
|
+
... rank=np.array([0, 0, 1]),
|
|
122
|
+
... crowding_distance=np.array([np.inf, np.inf, 0.5]),
|
|
123
|
+
... generations=1,
|
|
124
|
+
... evaluations=3,
|
|
125
|
+
... )
|
|
126
|
+
>>> len(result.pareto_front)
|
|
127
|
+
2
|
|
128
|
+
"""
|
|
129
|
+
# Find indices of rank-0 individuals
|
|
130
|
+
rank_0_mask = self.rank == 0
|
|
131
|
+
rank_0_indices = np.where(rank_0_mask)[0]
|
|
132
|
+
|
|
133
|
+
# Extract rank-0 individuals
|
|
134
|
+
x_front = self.population.x[rank_0_indices]
|
|
135
|
+
objectives_front = (
|
|
136
|
+
self.population.objectives[rank_0_indices] if self.population.objectives is not None else None
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return Population(x=x_front, objectives=objectives_front)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class GAResult:
|
|
144
|
+
"""Results from single-objective genetic algorithm optimization.
|
|
145
|
+
|
|
146
|
+
This class encapsulates the final population along with fitness values
|
|
147
|
+
for each individual. In minimization problems, lower fitness is better.
|
|
148
|
+
All arrays are copied on construction to ensure immutability.
|
|
149
|
+
|
|
150
|
+
Attributes
|
|
151
|
+
----------
|
|
152
|
+
population : Population
|
|
153
|
+
The final population after optimization.
|
|
154
|
+
fitness : numpy.ndarray
|
|
155
|
+
Fitness values for each individual. Lower values indicate better
|
|
156
|
+
fitness for minimization problems.
|
|
157
|
+
best_idx : int
|
|
158
|
+
Index of the best individual.
|
|
159
|
+
generations : int
|
|
160
|
+
Number of generations completed during optimization.
|
|
161
|
+
evaluations : int
|
|
162
|
+
Total number of objective function evaluations performed.
|
|
163
|
+
|
|
164
|
+
Examples
|
|
165
|
+
--------
|
|
166
|
+
>>> import numpy as np
|
|
167
|
+
>>> from ctrl_freak.population import Population
|
|
168
|
+
>>> from ctrl_freak.results import GAResult
|
|
169
|
+
>>> pop = Population(x=np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]))
|
|
170
|
+
>>> result = GAResult(
|
|
171
|
+
... population=pop,
|
|
172
|
+
... fitness=np.array([0.5, 0.3, 0.7]),
|
|
173
|
+
... best_idx=1,
|
|
174
|
+
... generations=50,
|
|
175
|
+
... evaluations=2500,
|
|
176
|
+
... )
|
|
177
|
+
>>> result.best[1]
|
|
178
|
+
0.3
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
population: Population
|
|
182
|
+
fitness: np.ndarray
|
|
183
|
+
best_idx: int
|
|
184
|
+
generations: int
|
|
185
|
+
evaluations: int
|
|
186
|
+
|
|
187
|
+
def __post_init__(self) -> None:
|
|
188
|
+
"""Validate shapes and copy arrays for immutability.
|
|
189
|
+
|
|
190
|
+
Raises
|
|
191
|
+
------
|
|
192
|
+
TypeError
|
|
193
|
+
If fitness is not a numpy array or best_idx is not an integer.
|
|
194
|
+
ValueError
|
|
195
|
+
If array shapes are inconsistent or best_idx is out of bounds.
|
|
196
|
+
"""
|
|
197
|
+
n = len(self.population)
|
|
198
|
+
|
|
199
|
+
# Validate fitness
|
|
200
|
+
if not isinstance(self.fitness, np.ndarray):
|
|
201
|
+
raise TypeError(f"fitness must be a numpy array, got {type(self.fitness).__name__}")
|
|
202
|
+
if self.fitness.ndim != 1:
|
|
203
|
+
raise ValueError(f"fitness must be 1D, got shape {self.fitness.shape}")
|
|
204
|
+
if self.fitness.shape[0] != n:
|
|
205
|
+
raise ValueError(f"fitness has {self.fitness.shape[0]} elements, expected {n} to match population size")
|
|
206
|
+
|
|
207
|
+
# Validate best_idx
|
|
208
|
+
if not isinstance(self.best_idx, (int, np.integer)):
|
|
209
|
+
raise TypeError(f"best_idx must be an integer, got {type(self.best_idx).__name__}")
|
|
210
|
+
if self.best_idx < 0 or self.best_idx >= n:
|
|
211
|
+
raise ValueError(f"best_idx {self.best_idx} is out of bounds for population with {n} individuals")
|
|
212
|
+
|
|
213
|
+
# Copy fitness for immutability (use object.__setattr__ for frozen dataclass)
|
|
214
|
+
object.__setattr__(self, "fitness", self.fitness.copy())
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def best(self) -> tuple[np.ndarray, float]:
|
|
218
|
+
"""Extract the best individual and its fitness value.
|
|
219
|
+
|
|
220
|
+
Returns
|
|
221
|
+
-------
|
|
222
|
+
tuple[numpy.ndarray, float]
|
|
223
|
+
Decision variables and fitness value for the best individual.
|
|
224
|
+
|
|
225
|
+
Examples
|
|
226
|
+
--------
|
|
227
|
+
>>> import numpy as np
|
|
228
|
+
>>> from ctrl_freak.population import Population
|
|
229
|
+
>>> from ctrl_freak.results import GAResult
|
|
230
|
+
>>> pop = Population(x=np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]))
|
|
231
|
+
>>> result = GAResult(
|
|
232
|
+
... population=pop,
|
|
233
|
+
... fitness=np.array([0.5, 0.3, 0.7]),
|
|
234
|
+
... best_idx=1,
|
|
235
|
+
... generations=1,
|
|
236
|
+
... evaluations=3,
|
|
237
|
+
... )
|
|
238
|
+
>>> best_x, best_fitness = result.best
|
|
239
|
+
>>> best_x
|
|
240
|
+
array([3., 4.])
|
|
241
|
+
>>> best_fitness
|
|
242
|
+
0.3
|
|
243
|
+
"""
|
|
244
|
+
best_x = self.population.x[self.best_idx]
|
|
245
|
+
best_fitness = self.fitness[self.best_idx]
|
|
246
|
+
return (best_x, float(best_fitness))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Selection strategies for genetic algorithms."""
|
|
2
|
+
|
|
3
|
+
from ctrl_freak.registry import SelectionRegistry
|
|
4
|
+
from ctrl_freak.selection.crowded import crowded_tournament
|
|
5
|
+
from ctrl_freak.selection.roulette import roulette_wheel
|
|
6
|
+
from ctrl_freak.selection.tournament import fitness_tournament
|
|
7
|
+
|
|
8
|
+
# Register built-in selection strategies
|
|
9
|
+
SelectionRegistry.register("crowded", crowded_tournament)
|
|
10
|
+
SelectionRegistry.register("tournament", fitness_tournament)
|
|
11
|
+
SelectionRegistry.register("roulette", roulette_wheel)
|
|
12
|
+
|
|
13
|
+
__all__ = ["crowded_tournament", "fitness_tournament", "roulette_wheel"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Crowded tournament selection for multi-objective optimization."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from ctrl_freak.population import Population
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def crowded_tournament(tournament_size: int = 2):
|
|
9
|
+
"""Create a crowded tournament parent selector.
|
|
10
|
+
|
|
11
|
+
In crowded tournament selection, individuals are compared by:
|
|
12
|
+
1. Pareto rank (lower is better)
|
|
13
|
+
2. If ranks are equal, crowding distance (higher is better for diversity)
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
tournament_size
|
|
18
|
+
Number of individuals in each tournament.
|
|
19
|
+
|
|
20
|
+
Returns
|
|
21
|
+
-------
|
|
22
|
+
callable
|
|
23
|
+
Parent selector that returns selected parent indices.
|
|
24
|
+
|
|
25
|
+
Examples
|
|
26
|
+
--------
|
|
27
|
+
>>> import numpy as np
|
|
28
|
+
>>> from ctrl_freak.population import Population
|
|
29
|
+
>>> from ctrl_freak.selection.crowded import crowded_tournament
|
|
30
|
+
>>> pop = Population(x=np.zeros((4, 2)), objectives=np.zeros((4, 2)))
|
|
31
|
+
>>> rng = np.random.default_rng(0)
|
|
32
|
+
>>> rank = np.array([0, 0, 1, 1])
|
|
33
|
+
>>> cd = np.array([1.0, 2.0, 1.0, 2.0])
|
|
34
|
+
>>> selector = crowded_tournament(tournament_size=2)
|
|
35
|
+
>>> parents = selector(pop, 6, rng, rank=rank, crowding_distance=cd)
|
|
36
|
+
>>> parents.shape
|
|
37
|
+
(6,)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def selector(
|
|
41
|
+
pop: Population,
|
|
42
|
+
n_parents: int,
|
|
43
|
+
rng: np.random.Generator,
|
|
44
|
+
**kwargs: np.ndarray,
|
|
45
|
+
) -> np.ndarray:
|
|
46
|
+
"""Select parents using crowded tournament selection.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
pop
|
|
51
|
+
Population to select from.
|
|
52
|
+
n_parents
|
|
53
|
+
Number of parents to select.
|
|
54
|
+
rng
|
|
55
|
+
Random number generator.
|
|
56
|
+
**kwargs
|
|
57
|
+
Must include ``rank`` and ``crowding_distance`` arrays.
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
numpy.ndarray
|
|
62
|
+
Selected parent indices.
|
|
63
|
+
|
|
64
|
+
Raises
|
|
65
|
+
------
|
|
66
|
+
ValueError
|
|
67
|
+
If ``rank`` or ``crowding_distance`` is missing.
|
|
68
|
+
|
|
69
|
+
Examples
|
|
70
|
+
--------
|
|
71
|
+
>>> import numpy as np
|
|
72
|
+
>>> from ctrl_freak.population import Population
|
|
73
|
+
>>> from ctrl_freak.selection.crowded import crowded_tournament
|
|
74
|
+
>>> pop = Population(x=np.zeros((4, 1)), objectives=np.zeros((4, 2)))
|
|
75
|
+
>>> selector = crowded_tournament()
|
|
76
|
+
>>> out = selector(
|
|
77
|
+
... pop,
|
|
78
|
+
... 3,
|
|
79
|
+
... np.random.default_rng(1),
|
|
80
|
+
... rank=np.array([0, 1, 0, 1]),
|
|
81
|
+
... crowding_distance=np.ones(4),
|
|
82
|
+
... )
|
|
83
|
+
>>> out.shape
|
|
84
|
+
(3,)
|
|
85
|
+
"""
|
|
86
|
+
# Validate required kwargs
|
|
87
|
+
if "rank" not in kwargs:
|
|
88
|
+
raise ValueError("crowded tournament selection requires 'rank' in kwargs")
|
|
89
|
+
if "crowding_distance" not in kwargs:
|
|
90
|
+
raise ValueError("crowded tournament selection requires 'crowding_distance' in kwargs")
|
|
91
|
+
|
|
92
|
+
rank = kwargs["rank"]
|
|
93
|
+
crowding_distance = kwargs["crowding_distance"]
|
|
94
|
+
pop_size = len(pop)
|
|
95
|
+
|
|
96
|
+
# Select n_parents winners via tournament
|
|
97
|
+
selected = np.empty(n_parents, dtype=np.intp)
|
|
98
|
+
|
|
99
|
+
for i in range(n_parents):
|
|
100
|
+
# Pick tournament_size random individuals
|
|
101
|
+
candidates = rng.integers(0, pop_size, size=tournament_size)
|
|
102
|
+
|
|
103
|
+
# Find winner: prefer lower rank, break ties with higher crowding distance
|
|
104
|
+
best_idx = candidates[0]
|
|
105
|
+
for c in candidates[1:]:
|
|
106
|
+
if (
|
|
107
|
+
rank[c] < rank[best_idx]
|
|
108
|
+
or rank[c] == rank[best_idx]
|
|
109
|
+
and crowding_distance[c] > crowding_distance[best_idx]
|
|
110
|
+
):
|
|
111
|
+
best_idx = c
|
|
112
|
+
|
|
113
|
+
selected[i] = best_idx
|
|
114
|
+
|
|
115
|
+
return selected
|
|
116
|
+
|
|
117
|
+
return selector
|