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,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)
@@ -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
+ ]