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,222 @@
|
|
|
1
|
+
"""Pareto-based ranking and diversity primitives for NSGA-II."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def dominates(a: np.ndarray, b: np.ndarray) -> bool:
|
|
7
|
+
"""Check if solution a Pareto-dominates solution b (minimization).
|
|
8
|
+
|
|
9
|
+
A solution ``a`` dominates ``b`` when it is no worse in every objective
|
|
10
|
+
and strictly better in at least one objective.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
a : numpy.ndarray
|
|
15
|
+
Objective values for solution ``a`` with shape ``(n_obj,)``.
|
|
16
|
+
b : numpy.ndarray
|
|
17
|
+
Objective values for solution ``b`` with shape ``(n_obj,)``.
|
|
18
|
+
|
|
19
|
+
Returns
|
|
20
|
+
-------
|
|
21
|
+
bool
|
|
22
|
+
``True`` if ``a`` dominates ``b``.
|
|
23
|
+
|
|
24
|
+
Examples
|
|
25
|
+
--------
|
|
26
|
+
>>> dominates(np.array([1.0, 2.0]), np.array([2.0, 3.0]))
|
|
27
|
+
True
|
|
28
|
+
>>> dominates(np.array([1.0, 3.0]), np.array([2.0, 2.0]))
|
|
29
|
+
False
|
|
30
|
+
"""
|
|
31
|
+
return bool(np.all(a <= b) and np.any(a < b))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def dominates_matrix(objectives: np.ndarray) -> np.ndarray:
|
|
35
|
+
"""Compute pairwise dominance for all individuals (vectorized).
|
|
36
|
+
|
|
37
|
+
Uses broadcasting to efficiently compute whether individual i dominates
|
|
38
|
+
individual j for all pairs (i, j).
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
objectives : numpy.ndarray
|
|
43
|
+
Objective values for all individuals with shape ``(n, n_obj)``.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
numpy.ndarray
|
|
48
|
+
Boolean array of shape ``(n, n)`` where ``result[i, j]`` is ``True``
|
|
49
|
+
iff individual ``i`` dominates individual ``j``.
|
|
50
|
+
|
|
51
|
+
Notes
|
|
52
|
+
-----
|
|
53
|
+
This implementation materializes an intermediate broadcast tensor with
|
|
54
|
+
memory complexity ``O(N^2 * M)`` for ``N`` individuals and ``M`` objectives.
|
|
55
|
+
The result is correct and vectorized; for very large ``N`` or ``M``, prefer
|
|
56
|
+
a chunked reduction.
|
|
57
|
+
|
|
58
|
+
Examples
|
|
59
|
+
--------
|
|
60
|
+
>>> objs = np.array([[1.0, 1.0], [2.0, 2.0], [1.0, 2.0]])
|
|
61
|
+
>>> dom = dominates_matrix(objs)
|
|
62
|
+
>>> bool(dom[0, 1]) # Does [1,1] dominate [2,2]?
|
|
63
|
+
True
|
|
64
|
+
>>> bool(dom[0, 2]) # Does [1,1] dominate [1,2]?
|
|
65
|
+
True
|
|
66
|
+
"""
|
|
67
|
+
# Reshape for broadcasting: (n, 1, n_obj) vs (1, n, n_obj)
|
|
68
|
+
a = objectives[:, np.newaxis, :] # (n, 1, n_obj)
|
|
69
|
+
b = objectives[np.newaxis, :, :] # (1, n, n_obj)
|
|
70
|
+
|
|
71
|
+
# a[i] <= b[j] for all objectives
|
|
72
|
+
all_leq = np.all(a <= b, axis=2) # (n, n)
|
|
73
|
+
|
|
74
|
+
# a[i] < b[j] for at least one objective
|
|
75
|
+
any_lt = np.any(a < b, axis=2) # (n, n)
|
|
76
|
+
|
|
77
|
+
return all_leq & any_lt
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def non_dominated_sort(objectives: np.ndarray) -> np.ndarray:
|
|
81
|
+
"""Assign each individual to a Pareto front using Deb's fast algorithm.
|
|
82
|
+
|
|
83
|
+
Implements the fast non-dominated sorting algorithm from NSGA-II.
|
|
84
|
+
The time complexity is ``O(M * N^2)`` where ``M`` is the number of
|
|
85
|
+
objectives and ``N`` is the population size.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
objectives : numpy.ndarray
|
|
90
|
+
Objective values for all individuals with shape ``(n, n_obj)``.
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
numpy.ndarray
|
|
95
|
+
Integer array of shape ``(n,)`` where ``rank[i]`` is the front index
|
|
96
|
+
for individual ``i``. Rank 0 is the first Pareto front.
|
|
97
|
+
|
|
98
|
+
Examples
|
|
99
|
+
--------
|
|
100
|
+
>>> objs = np.array([[1.0, 1.0], [2.0, 2.0], [3.0, 3.0]])
|
|
101
|
+
>>> non_dominated_sort(objs)
|
|
102
|
+
array([0, 1, 2])
|
|
103
|
+
"""
|
|
104
|
+
n = objectives.shape[0]
|
|
105
|
+
|
|
106
|
+
if n == 0:
|
|
107
|
+
return np.array([], dtype=np.int64)
|
|
108
|
+
|
|
109
|
+
# Compute pairwise dominance matrix
|
|
110
|
+
dom_matrix = dominates_matrix(objectives)
|
|
111
|
+
|
|
112
|
+
# domination_count[i] = number of individuals that dominate i
|
|
113
|
+
domination_count = dom_matrix.sum(axis=0)
|
|
114
|
+
|
|
115
|
+
# Initialize ranks array
|
|
116
|
+
ranks = np.full(n, -1, dtype=np.int64)
|
|
117
|
+
|
|
118
|
+
current_rank = 0
|
|
119
|
+
remaining = np.arange(n)
|
|
120
|
+
|
|
121
|
+
while len(remaining) > 0:
|
|
122
|
+
# Find individuals with zero domination count (current front)
|
|
123
|
+
front_mask = domination_count[remaining] == 0
|
|
124
|
+
front = remaining[front_mask]
|
|
125
|
+
|
|
126
|
+
if len(front) == 0:
|
|
127
|
+
# Safety check: if no front found but individuals remain,
|
|
128
|
+
# assign remaining to current rank (shouldn't happen with valid data)
|
|
129
|
+
ranks[remaining] = current_rank
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
# Assign rank to front members
|
|
133
|
+
ranks[front] = current_rank
|
|
134
|
+
|
|
135
|
+
# Remove front members from remaining
|
|
136
|
+
remaining = remaining[~front_mask]
|
|
137
|
+
|
|
138
|
+
# Update domination counts: subtract dominance from front members
|
|
139
|
+
# Vectorized: sum all dominance contributions from front members at once.
|
|
140
|
+
# dom_matrix[front][:, remaining] is (len(front), len(remaining)) boolean matrix.
|
|
141
|
+
# Summing axis=0 gives total front members dominating each remaining individual.
|
|
142
|
+
if len(remaining) > 0:
|
|
143
|
+
dom_from_front = dom_matrix[front][:, remaining].sum(axis=0)
|
|
144
|
+
domination_count[remaining] -= dom_from_front
|
|
145
|
+
|
|
146
|
+
current_rank += 1
|
|
147
|
+
|
|
148
|
+
return ranks
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def crowding_distance(front_objectives: np.ndarray) -> np.ndarray:
|
|
152
|
+
"""Compute crowding distance for individuals in a single Pareto front.
|
|
153
|
+
|
|
154
|
+
Crowding distance measures how isolated a solution is in objective space.
|
|
155
|
+
Higher values indicate more isolated solutions (preferred for diversity).
|
|
156
|
+
|
|
157
|
+
Boundary solutions (with min/max values for any objective) receive
|
|
158
|
+
infinite distance. Interior solutions receive the sum of normalized
|
|
159
|
+
neighbor distances across all objectives.
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
front_objectives : numpy.ndarray
|
|
164
|
+
Objective values for individuals in one front only, with shape
|
|
165
|
+
``(n_front, n_obj)``.
|
|
166
|
+
|
|
167
|
+
Returns
|
|
168
|
+
-------
|
|
169
|
+
numpy.ndarray
|
|
170
|
+
Array of shape ``(n_front,)`` containing crowding distances. Higher
|
|
171
|
+
values indicate more isolated solutions.
|
|
172
|
+
|
|
173
|
+
Examples
|
|
174
|
+
--------
|
|
175
|
+
>>> objs = np.array([[1.0, 4.0], [2.0, 3.0], [3.0, 2.0], [4.0, 1.0]])
|
|
176
|
+
>>> cd = crowding_distance(objs)
|
|
177
|
+
>>> bool(np.isinf(cd[0]) and np.isinf(cd[-1])) # Boundary points
|
|
178
|
+
True
|
|
179
|
+
"""
|
|
180
|
+
n_front = front_objectives.shape[0]
|
|
181
|
+
|
|
182
|
+
if n_front == 0:
|
|
183
|
+
return np.array([], dtype=np.float64)
|
|
184
|
+
|
|
185
|
+
if n_front == 1:
|
|
186
|
+
# Single individual gets infinite distance (it's both min and max)
|
|
187
|
+
return np.array([np.inf])
|
|
188
|
+
|
|
189
|
+
if n_front == 2:
|
|
190
|
+
# Two individuals are both boundary points
|
|
191
|
+
return np.array([np.inf, np.inf])
|
|
192
|
+
|
|
193
|
+
n_obj = front_objectives.shape[1]
|
|
194
|
+
distances = np.zeros(n_front, dtype=np.float64)
|
|
195
|
+
|
|
196
|
+
for m in range(n_obj):
|
|
197
|
+
# Sort indices by objective m
|
|
198
|
+
sorted_indices = np.argsort(front_objectives[:, m])
|
|
199
|
+
|
|
200
|
+
# Objective range for normalization
|
|
201
|
+
obj_min = front_objectives[sorted_indices[0], m]
|
|
202
|
+
obj_max = front_objectives[sorted_indices[-1], m]
|
|
203
|
+
obj_range = obj_max - obj_min
|
|
204
|
+
|
|
205
|
+
# Boundary points get infinite distance
|
|
206
|
+
distances[sorted_indices[0]] = np.inf
|
|
207
|
+
distances[sorted_indices[-1]] = np.inf
|
|
208
|
+
|
|
209
|
+
# Interior points: add normalized neighbor distance (vectorized)
|
|
210
|
+
if obj_range > 0:
|
|
211
|
+
# Get objective values in sorted order
|
|
212
|
+
sorted_values = front_objectives[sorted_indices, m]
|
|
213
|
+
|
|
214
|
+
# Compute neighbor distances for all interior points at once
|
|
215
|
+
# For position i: dist = (value[i+1] - value[i-1]) / range
|
|
216
|
+
neighbor_dists = (sorted_values[2:] - sorted_values[:-2]) / obj_range
|
|
217
|
+
|
|
218
|
+
# Add to original indices of interior points
|
|
219
|
+
interior_indices = sorted_indices[1:-1]
|
|
220
|
+
distances[interior_indices] += neighbor_dists
|
|
221
|
+
|
|
222
|
+
return distances
|
ctrl_freak/protocols.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Protocol definitions for selection strategies in evolutionary algorithms.
|
|
2
|
+
|
|
3
|
+
This module defines the core protocols (interfaces) for selection strategies used
|
|
4
|
+
in evolutionary algorithms like NSGA-II and genetic algorithms. These protocols
|
|
5
|
+
enable a pluggable architecture where different selection strategies can be
|
|
6
|
+
swapped without changing the algorithm implementation.
|
|
7
|
+
|
|
8
|
+
The two main selection phases in evolutionary algorithms are:
|
|
9
|
+
|
|
10
|
+
1. **Parent Selection**: Choosing individuals from the current population to
|
|
11
|
+
create offspring. Strategies include tournament selection, roulette wheel,
|
|
12
|
+
and crowded tournament selection (NSGA-II).
|
|
13
|
+
|
|
14
|
+
2. **Survivor Selection**: Determining which individuals from a combined
|
|
15
|
+
parent+offspring population survive to the next generation. Strategies
|
|
16
|
+
include NSGA-II non-dominated sorting with crowding, truncation selection,
|
|
17
|
+
and elitist strategies.
|
|
18
|
+
|
|
19
|
+
Examples
|
|
20
|
+
--------
|
|
21
|
+
Parent and survivor selectors are usually passed into algorithms as callables::
|
|
22
|
+
|
|
23
|
+
def my_algorithm(parent_selector, survivor_selector, pop, rng, state):
|
|
24
|
+
parent_indices = parent_selector(pop, n_parents=100, rng=rng, **state)
|
|
25
|
+
survivor_indices, new_state = survivor_selector(
|
|
26
|
+
pop,
|
|
27
|
+
n_survivors=100,
|
|
28
|
+
parent_size=100,
|
|
29
|
+
)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from typing import Any, Protocol, runtime_checkable
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
|
|
36
|
+
from ctrl_freak.population import Population
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@runtime_checkable
|
|
40
|
+
class ParentSelector(Protocol):
|
|
41
|
+
"""Protocol for parent selection strategies.
|
|
42
|
+
|
|
43
|
+
Parent selectors choose which individuals from a population will be used
|
|
44
|
+
as parents for creating offspring. Different strategies (tournament,
|
|
45
|
+
roulette wheel, crowded selection) implement this protocol.
|
|
46
|
+
|
|
47
|
+
The selector is called with the population, number of parents to select,
|
|
48
|
+
a random number generator, and optional keyword arguments containing
|
|
49
|
+
algorithm-specific data (e.g., rank, crowding_distance for NSGA-II).
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
pop : Population
|
|
54
|
+
The current population to select parents from.
|
|
55
|
+
n_parents : int
|
|
56
|
+
Number of parent indices to return. The same individual may be selected
|
|
57
|
+
multiple times depending on the strategy.
|
|
58
|
+
rng : numpy.random.Generator
|
|
59
|
+
Random number generator for reproducible stochastic selection.
|
|
60
|
+
**kwargs : numpy.ndarray
|
|
61
|
+
Algorithm-specific state data. NSGA-II selectors typically receive
|
|
62
|
+
``rank`` and ``crowding_distance`` arrays. Single-objective selectors
|
|
63
|
+
may receive ``fitness`` values.
|
|
64
|
+
|
|
65
|
+
Returns
|
|
66
|
+
-------
|
|
67
|
+
numpy.ndarray
|
|
68
|
+
Indices into the population. Shape is ``(n_parents,)`` with values in
|
|
69
|
+
``[0, len(pop))``.
|
|
70
|
+
|
|
71
|
+
Example implementations:
|
|
72
|
+
- Tournament selection: Compare k random individuals, select best
|
|
73
|
+
- Roulette wheel: Probability proportional to fitness
|
|
74
|
+
- Crowded tournament (NSGA-II): Compare by rank, then crowding distance
|
|
75
|
+
- Random selection: Uniform random choice
|
|
76
|
+
|
|
77
|
+
Examples
|
|
78
|
+
--------
|
|
79
|
+
A selector implementation is a callable with the protocol signature::
|
|
80
|
+
|
|
81
|
+
def tournament_selector(pop, n_parents, rng, **kwargs):
|
|
82
|
+
fitness = kwargs["fitness"]
|
|
83
|
+
indices = []
|
|
84
|
+
for _ in range(n_parents):
|
|
85
|
+
candidates = rng.choice(len(pop), size=2, replace=False)
|
|
86
|
+
winner = candidates[np.argmin(fitness[candidates])]
|
|
87
|
+
indices.append(winner)
|
|
88
|
+
return np.array(indices)
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __call__(
|
|
92
|
+
self,
|
|
93
|
+
pop: Population,
|
|
94
|
+
n_parents: int,
|
|
95
|
+
rng: np.random.Generator,
|
|
96
|
+
**kwargs: np.ndarray,
|
|
97
|
+
) -> np.ndarray:
|
|
98
|
+
"""Select parent indices from the population.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
pop : Population
|
|
103
|
+
The current population to select parents from.
|
|
104
|
+
n_parents : int
|
|
105
|
+
Number of parent indices to return.
|
|
106
|
+
rng : numpy.random.Generator
|
|
107
|
+
Random number generator for reproducibility.
|
|
108
|
+
**kwargs : numpy.ndarray
|
|
109
|
+
Algorithm-specific state, such as ``rank`` or
|
|
110
|
+
``crowding_distance``.
|
|
111
|
+
|
|
112
|
+
Returns
|
|
113
|
+
-------
|
|
114
|
+
numpy.ndarray
|
|
115
|
+
Array of shape ``(n_parents,)`` containing selected parent indices.
|
|
116
|
+
"""
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@runtime_checkable
|
|
121
|
+
class SurvivorSelector(Protocol):
|
|
122
|
+
"""Protocol for survivor selection strategies.
|
|
123
|
+
|
|
124
|
+
Survivor selectors determine which individuals survive to the next generation
|
|
125
|
+
from a combined parent+offspring population. Different strategies (NSGA-II,
|
|
126
|
+
truncation, elitist) implement this protocol.
|
|
127
|
+
|
|
128
|
+
The selector is called with the population, number of survivors to select,
|
|
129
|
+
and optional keyword arguments containing algorithm-specific data.
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
pop : Population
|
|
134
|
+
Combined population, typically parents plus offspring.
|
|
135
|
+
n_survivors : int
|
|
136
|
+
Number of individuals to select for the next generation.
|
|
137
|
+
**kwargs : Any
|
|
138
|
+
Algorithm-specific input data. Values may include arrays, pre-computed
|
|
139
|
+
metrics, or non-array controls such as the integer ``parent_size``.
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
tuple[numpy.ndarray, dict[str, numpy.ndarray]]
|
|
144
|
+
Selected survivor indices and algorithm-specific state for the next
|
|
145
|
+
generation.
|
|
146
|
+
|
|
147
|
+
Example implementations:
|
|
148
|
+
- NSGA-II: Non-dominated sorting + crowding distance truncation
|
|
149
|
+
- Truncation: Keep top n_survivors by fitness
|
|
150
|
+
- Elitist: Always keep best individual, random for rest
|
|
151
|
+
- Age-based: Prefer younger individuals
|
|
152
|
+
|
|
153
|
+
Examples
|
|
154
|
+
--------
|
|
155
|
+
A selector implementation returns survivor indices and state::
|
|
156
|
+
|
|
157
|
+
def truncation_selector(pop, n_survivors, **kwargs):
|
|
158
|
+
fitness = pop.objectives[:, 0]
|
|
159
|
+
survivor_indices = np.argsort(fitness)[:n_survivors]
|
|
160
|
+
return survivor_indices, {"fitness": fitness[survivor_indices]}
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __call__(
|
|
164
|
+
self,
|
|
165
|
+
pop: Population,
|
|
166
|
+
n_survivors: int,
|
|
167
|
+
**kwargs: Any,
|
|
168
|
+
) -> tuple[np.ndarray, dict[str, np.ndarray]]:
|
|
169
|
+
"""Select survivor indices from the population.
|
|
170
|
+
|
|
171
|
+
Parameters
|
|
172
|
+
----------
|
|
173
|
+
pop : Population
|
|
174
|
+
The combined population to select survivors from.
|
|
175
|
+
n_survivors : int
|
|
176
|
+
Number of survivors to select.
|
|
177
|
+
**kwargs : Any
|
|
178
|
+
Algorithm-specific input data, including non-array values such as
|
|
179
|
+
integer ``parent_size``.
|
|
180
|
+
|
|
181
|
+
Returns
|
|
182
|
+
-------
|
|
183
|
+
tuple[numpy.ndarray, dict[str, numpy.ndarray]]
|
|
184
|
+
Selected survivor indices and state data for the next generation.
|
|
185
|
+
"""
|
|
186
|
+
...
|
ctrl_freak/py.typed
ADDED
|
File without changes
|