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.
@@ -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
@@ -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