desdeo 1.2__py3-none-any.whl → 2.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.
Files changed (182) hide show
  1. desdeo/__init__.py +8 -8
  2. desdeo/adm/ADMAfsar.py +551 -0
  3. desdeo/adm/ADMChen.py +414 -0
  4. desdeo/adm/BaseADM.py +119 -0
  5. desdeo/adm/__init__.py +11 -0
  6. desdeo/api/README.md +73 -0
  7. desdeo/api/__init__.py +15 -0
  8. desdeo/api/app.py +50 -0
  9. desdeo/api/config.py +90 -0
  10. desdeo/api/config.toml +64 -0
  11. desdeo/api/db.py +27 -0
  12. desdeo/api/db_init.py +85 -0
  13. desdeo/api/db_models.py +164 -0
  14. desdeo/api/malaga_db_init.py +27 -0
  15. desdeo/api/models/__init__.py +266 -0
  16. desdeo/api/models/archive.py +23 -0
  17. desdeo/api/models/emo.py +128 -0
  18. desdeo/api/models/enautilus.py +69 -0
  19. desdeo/api/models/gdm/gdm_aggregate.py +139 -0
  20. desdeo/api/models/gdm/gdm_base.py +69 -0
  21. desdeo/api/models/gdm/gdm_score_bands.py +114 -0
  22. desdeo/api/models/gdm/gnimbus.py +138 -0
  23. desdeo/api/models/generic.py +104 -0
  24. desdeo/api/models/generic_states.py +401 -0
  25. desdeo/api/models/nimbus.py +158 -0
  26. desdeo/api/models/preference.py +128 -0
  27. desdeo/api/models/problem.py +717 -0
  28. desdeo/api/models/reference_point_method.py +18 -0
  29. desdeo/api/models/session.py +49 -0
  30. desdeo/api/models/state.py +463 -0
  31. desdeo/api/models/user.py +52 -0
  32. desdeo/api/models/utopia.py +25 -0
  33. desdeo/api/routers/_EMO.backup +309 -0
  34. desdeo/api/routers/_NAUTILUS.py +245 -0
  35. desdeo/api/routers/_NAUTILUS_navigator.py +233 -0
  36. desdeo/api/routers/_NIMBUS.py +765 -0
  37. desdeo/api/routers/__init__.py +5 -0
  38. desdeo/api/routers/emo.py +497 -0
  39. desdeo/api/routers/enautilus.py +237 -0
  40. desdeo/api/routers/gdm/gdm_aggregate.py +234 -0
  41. desdeo/api/routers/gdm/gdm_base.py +420 -0
  42. desdeo/api/routers/gdm/gdm_score_bands/gdm_score_bands_manager.py +398 -0
  43. desdeo/api/routers/gdm/gdm_score_bands/gdm_score_bands_routers.py +377 -0
  44. desdeo/api/routers/gdm/gnimbus/gnimbus_manager.py +698 -0
  45. desdeo/api/routers/gdm/gnimbus/gnimbus_routers.py +591 -0
  46. desdeo/api/routers/generic.py +233 -0
  47. desdeo/api/routers/nimbus.py +705 -0
  48. desdeo/api/routers/problem.py +307 -0
  49. desdeo/api/routers/reference_point_method.py +93 -0
  50. desdeo/api/routers/session.py +100 -0
  51. desdeo/api/routers/test.py +16 -0
  52. desdeo/api/routers/user_authentication.py +520 -0
  53. desdeo/api/routers/utils.py +187 -0
  54. desdeo/api/routers/utopia.py +230 -0
  55. desdeo/api/schema.py +100 -0
  56. desdeo/api/tests/__init__.py +0 -0
  57. desdeo/api/tests/conftest.py +151 -0
  58. desdeo/api/tests/test_enautilus.py +330 -0
  59. desdeo/api/tests/test_models.py +1179 -0
  60. desdeo/api/tests/test_routes.py +1075 -0
  61. desdeo/api/utils/_database.py +263 -0
  62. desdeo/api/utils/_logger.py +29 -0
  63. desdeo/api/utils/database.py +36 -0
  64. desdeo/api/utils/emo_database.py +40 -0
  65. desdeo/core.py +34 -0
  66. desdeo/emo/__init__.py +159 -0
  67. desdeo/emo/hooks/archivers.py +188 -0
  68. desdeo/emo/methods/EAs.py +541 -0
  69. desdeo/emo/methods/__init__.py +0 -0
  70. desdeo/emo/methods/bases.py +12 -0
  71. desdeo/emo/methods/templates.py +111 -0
  72. desdeo/emo/operators/__init__.py +1 -0
  73. desdeo/emo/operators/crossover.py +1282 -0
  74. desdeo/emo/operators/evaluator.py +114 -0
  75. desdeo/emo/operators/generator.py +459 -0
  76. desdeo/emo/operators/mutation.py +1224 -0
  77. desdeo/emo/operators/scalar_selection.py +202 -0
  78. desdeo/emo/operators/selection.py +1778 -0
  79. desdeo/emo/operators/termination.py +286 -0
  80. desdeo/emo/options/__init__.py +108 -0
  81. desdeo/emo/options/algorithms.py +435 -0
  82. desdeo/emo/options/crossover.py +164 -0
  83. desdeo/emo/options/generator.py +131 -0
  84. desdeo/emo/options/mutation.py +260 -0
  85. desdeo/emo/options/repair.py +61 -0
  86. desdeo/emo/options/scalar_selection.py +66 -0
  87. desdeo/emo/options/selection.py +127 -0
  88. desdeo/emo/options/templates.py +383 -0
  89. desdeo/emo/options/termination.py +143 -0
  90. desdeo/explanations/__init__.py +6 -0
  91. desdeo/explanations/explainer.py +100 -0
  92. desdeo/explanations/utils.py +90 -0
  93. desdeo/gdm/__init__.py +22 -0
  94. desdeo/gdm/gdmtools.py +45 -0
  95. desdeo/gdm/score_bands.py +114 -0
  96. desdeo/gdm/voting_rules.py +50 -0
  97. desdeo/mcdm/__init__.py +41 -0
  98. desdeo/mcdm/enautilus.py +338 -0
  99. desdeo/mcdm/gnimbus.py +484 -0
  100. desdeo/mcdm/nautili.py +345 -0
  101. desdeo/mcdm/nautilus.py +477 -0
  102. desdeo/mcdm/nautilus_navigator.py +656 -0
  103. desdeo/mcdm/nimbus.py +417 -0
  104. desdeo/mcdm/pareto_navigator.py +269 -0
  105. desdeo/mcdm/reference_point_method.py +186 -0
  106. desdeo/problem/__init__.py +83 -0
  107. desdeo/problem/evaluator.py +561 -0
  108. desdeo/problem/external/__init__.py +18 -0
  109. desdeo/problem/external/core.py +356 -0
  110. desdeo/problem/external/pymoo_provider.py +266 -0
  111. desdeo/problem/external/runtime.py +44 -0
  112. desdeo/problem/gurobipy_evaluator.py +562 -0
  113. desdeo/problem/infix_parser.py +341 -0
  114. desdeo/problem/json_parser.py +944 -0
  115. desdeo/problem/pyomo_evaluator.py +487 -0
  116. desdeo/problem/schema.py +1829 -0
  117. desdeo/problem/simulator_evaluator.py +348 -0
  118. desdeo/problem/sympy_evaluator.py +244 -0
  119. desdeo/problem/testproblems/__init__.py +88 -0
  120. desdeo/problem/testproblems/benchmarks_server.py +120 -0
  121. desdeo/problem/testproblems/binh_and_korn_problem.py +88 -0
  122. desdeo/problem/testproblems/cake_problem.py +185 -0
  123. desdeo/problem/testproblems/dmitry_forest_problem_discrete.py +71 -0
  124. desdeo/problem/testproblems/dtlz2_problem.py +102 -0
  125. desdeo/problem/testproblems/forest_problem.py +283 -0
  126. desdeo/problem/testproblems/knapsack_problem.py +163 -0
  127. desdeo/problem/testproblems/mcwb_problem.py +831 -0
  128. desdeo/problem/testproblems/mixed_variable_dimenrions_problem.py +83 -0
  129. desdeo/problem/testproblems/momip_problem.py +172 -0
  130. desdeo/problem/testproblems/multi_valued_constraints.py +119 -0
  131. desdeo/problem/testproblems/nimbus_problem.py +143 -0
  132. desdeo/problem/testproblems/pareto_navigator_problem.py +89 -0
  133. desdeo/problem/testproblems/re_problem.py +492 -0
  134. desdeo/problem/testproblems/river_pollution_problems.py +440 -0
  135. desdeo/problem/testproblems/rocket_injector_design_problem.py +140 -0
  136. desdeo/problem/testproblems/simple_problem.py +351 -0
  137. desdeo/problem/testproblems/simulator_problem.py +92 -0
  138. desdeo/problem/testproblems/single_objective.py +289 -0
  139. desdeo/problem/testproblems/spanish_sustainability_problem.py +945 -0
  140. desdeo/problem/testproblems/zdt_problem.py +274 -0
  141. desdeo/problem/utils.py +245 -0
  142. desdeo/tools/GenerateReferencePoints.py +181 -0
  143. desdeo/tools/__init__.py +120 -0
  144. desdeo/tools/desc_gen.py +22 -0
  145. desdeo/tools/generics.py +165 -0
  146. desdeo/tools/group_scalarization.py +3090 -0
  147. desdeo/tools/gurobipy_solver_interfaces.py +258 -0
  148. desdeo/tools/indicators_binary.py +117 -0
  149. desdeo/tools/indicators_unary.py +362 -0
  150. desdeo/tools/interaction_schema.py +38 -0
  151. desdeo/tools/intersection.py +54 -0
  152. desdeo/tools/iterative_pareto_representer.py +99 -0
  153. desdeo/tools/message.py +265 -0
  154. desdeo/tools/ng_solver_interfaces.py +199 -0
  155. desdeo/tools/non_dominated_sorting.py +134 -0
  156. desdeo/tools/patterns.py +283 -0
  157. desdeo/tools/proximal_solver.py +99 -0
  158. desdeo/tools/pyomo_solver_interfaces.py +477 -0
  159. desdeo/tools/reference_vectors.py +229 -0
  160. desdeo/tools/scalarization.py +2065 -0
  161. desdeo/tools/scipy_solver_interfaces.py +454 -0
  162. desdeo/tools/score_bands.py +627 -0
  163. desdeo/tools/utils.py +388 -0
  164. desdeo/tools/visualizations.py +67 -0
  165. desdeo/utopia_stuff/__init__.py +0 -0
  166. desdeo/utopia_stuff/data/1.json +15 -0
  167. desdeo/utopia_stuff/data/2.json +13 -0
  168. desdeo/utopia_stuff/data/3.json +15 -0
  169. desdeo/utopia_stuff/data/4.json +17 -0
  170. desdeo/utopia_stuff/data/5.json +15 -0
  171. desdeo/utopia_stuff/from_json.py +40 -0
  172. desdeo/utopia_stuff/reinit_user.py +38 -0
  173. desdeo/utopia_stuff/utopia_db_init.py +212 -0
  174. desdeo/utopia_stuff/utopia_problem.py +403 -0
  175. desdeo/utopia_stuff/utopia_problem_old.py +415 -0
  176. desdeo/utopia_stuff/utopia_reference_solutions.py +79 -0
  177. desdeo-2.1.0.dist-info/METADATA +186 -0
  178. desdeo-2.1.0.dist-info/RECORD +180 -0
  179. {desdeo-1.2.dist-info → desdeo-2.1.0.dist-info}/WHEEL +1 -1
  180. desdeo-2.1.0.dist-info/licenses/LICENSE +21 -0
  181. desdeo-1.2.dist-info/METADATA +0 -16
  182. desdeo-1.2.dist-info/RECORD +0 -4
@@ -0,0 +1,1282 @@
1
+ """Evolutionary operators for recombination.
2
+
3
+ Various evolutionary operators for recombination
4
+ in multiobjective optimization are defined here.
5
+ """
6
+
7
+ import copy
8
+ from abc import abstractmethod
9
+ from collections.abc import Sequence
10
+
11
+ import numpy as np
12
+ import polars as pl
13
+
14
+ from desdeo.problem import Problem, VariableDomainTypeEnum
15
+ from desdeo.tools.message import (
16
+ CrossoverMessageTopics,
17
+ FloatMessage,
18
+ Message,
19
+ PolarsDataFrameMessage,
20
+ )
21
+ from desdeo.tools.patterns import Publisher, Subscriber
22
+
23
+
24
+ class BaseCrossover(Subscriber):
25
+ """A base class for crossover operators."""
26
+
27
+ def __init__(self, problem: Problem, verbosity: int, publisher: Publisher):
28
+ """Initialize a crossover operator."""
29
+ super().__init__(verbosity=verbosity, publisher=publisher)
30
+ self.problem = problem
31
+ self.variable_symbols = [var.symbol for var in problem.get_flattened_variables()]
32
+ self.lower_bounds = [var.lowerbound for var in problem.get_flattened_variables()]
33
+ self.upper_bounds = [var.upperbound for var in problem.get_flattened_variables()]
34
+
35
+ self.variable_types = [var.variable_type for var in problem.get_flattened_variables()]
36
+ self.variable_combination: VariableDomainTypeEnum = problem.variable_domain
37
+
38
+ @abstractmethod
39
+ def do(self, *, population: pl.DataFrame, to_mate: list[int] | None = None) -> pl.DataFrame:
40
+ """Perform the crossover operation.
41
+
42
+ Args:
43
+ population (pl.DataFrame): the population to perform the crossover with. The DataFrame
44
+ contains the decision vectors, the target vectors, and the constraint vectors.
45
+ to_mate (list[int] | None): the indices of the population members that should
46
+ participate in the crossover. If `None`, the whole population is subject
47
+ to the crossover.
48
+
49
+ Returns:
50
+ pl.DataFrame: the offspring resulting from the crossover.
51
+ """
52
+
53
+
54
+ class SimulatedBinaryCrossover(BaseCrossover):
55
+ """A class for creating a simulated binary crossover operator.
56
+
57
+ Reference:
58
+ Kalyanmoy Deb and Ram Bhushan Agrawal. 1995. Simulated binary crossover for continuous search space.
59
+ Complex Systems 9, 2 (1995), 115-148.
60
+ """
61
+
62
+ @property
63
+ def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
64
+ """The message topics provided by the crossover operator."""
65
+ return {
66
+ 0: [],
67
+ 1: [CrossoverMessageTopics.XOVER_PROBABILITY, CrossoverMessageTopics.XOVER_DISTRIBUTION],
68
+ 2: [
69
+ CrossoverMessageTopics.XOVER_PROBABILITY,
70
+ CrossoverMessageTopics.XOVER_DISTRIBUTION,
71
+ CrossoverMessageTopics.PARENTS,
72
+ CrossoverMessageTopics.OFFSPRINGS,
73
+ ],
74
+ }
75
+
76
+ @property
77
+ def interested_topics(self):
78
+ """The message topics the crossover operator is interested in."""
79
+ return []
80
+
81
+ def __init__(
82
+ self,
83
+ *,
84
+ problem: Problem,
85
+ seed: int,
86
+ verbosity: int,
87
+ publisher: Publisher,
88
+ xover_probability: float = 1.0,
89
+ xover_distribution: float = 30,
90
+ ):
91
+ """Initialize a simulated binary crossover operator.
92
+
93
+ Args:
94
+ problem (Problem): the problem object.
95
+ seed (int): the seed for the random number generator.
96
+ verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
97
+ topics are provided by the operator at each verbosity level. Recommended to be set to 1.
98
+ publisher (Publisher): the publisher to which the operator will publish messages.
99
+ xover_probability (float, optional): the crossover probability
100
+ parameter. Ranges between 0 and 1.0. Defaults to 1.0.
101
+ xover_distribution (float, optional): the crossover distribution
102
+ parameter. Must be positive. Defaults to 30.
103
+ """
104
+ # Subscribes to no topics, so no need to stroe/pass the topics to the super class.
105
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
106
+ self.problem = problem
107
+
108
+ if not 0 <= xover_probability <= 1:
109
+ raise ValueError("Crossover probability must be between 0 and 1.")
110
+ if xover_distribution <= 0:
111
+ raise ValueError("Crossover distribution must be positive.")
112
+ self.xover_probability = xover_probability
113
+ self.xover_distribution = xover_distribution
114
+ self.parent_population: pl.DataFrame
115
+ self.offspring_population: pl.DataFrame
116
+ self.rng = np.random.default_rng(seed)
117
+ self.seed = seed
118
+
119
+ def do(
120
+ self,
121
+ *,
122
+ population: pl.DataFrame,
123
+ to_mate: list[int] | None = None,
124
+ ) -> pl.DataFrame:
125
+ """Perform the simulated binary crossover operation.
126
+
127
+ Args:
128
+ population (pl.DataFrame): the population to perform the crossover with. The DataFrame
129
+ contains the decision vectors, the target vectors, and the constraint vectors.
130
+ to_mate (list[int] | None): the indices of the population members that should
131
+ participate in the crossover. If `None`, the whole population is subject
132
+ to the crossover.
133
+
134
+ Returns:
135
+ pl.DataFrame: the offspring resulting from the crossover.
136
+ """
137
+ self.parent_population = population
138
+ pop_size = self.parent_population.shape[0]
139
+ num_var = len(self.variable_symbols)
140
+
141
+ parent_decvars = self.parent_population[self.variable_symbols].to_numpy()
142
+
143
+ if to_mate is None:
144
+ shuffled_ids = list(range(pop_size))
145
+ self.rng.shuffle(shuffled_ids)
146
+ else:
147
+ shuffled_ids = to_mate
148
+ mating_pop = parent_decvars[shuffled_ids]
149
+ mate_size = len(shuffled_ids)
150
+
151
+ if len(shuffled_ids) % 2 == 1:
152
+ mating_pop = np.vstack((mating_pop, mating_pop[0]))
153
+ mate_size += 1
154
+
155
+ offspring = np.zeros_like(mating_pop)
156
+
157
+ HALF = 0.5 # NOQA: N806
158
+ # TODO(@light-weaver): Extract into a numba jitted function.
159
+ for i in range(0, mate_size, 2):
160
+ beta = np.zeros(num_var)
161
+ miu = self.rng.random(num_var)
162
+ beta[miu <= HALF] = (2 * miu[miu <= HALF]) ** (1 / (self.xover_distribution + 1))
163
+ beta[miu > HALF] = (2 - 2 * miu[miu > HALF]) ** (-1 / (self.xover_distribution + 1))
164
+ beta = beta * ((-1) ** self.rng.integers(low=0, high=2, size=num_var))
165
+ beta[self.rng.random(num_var) > self.xover_probability] = 1
166
+ avg = (mating_pop[i] + mating_pop[i + 1]) / 2
167
+ diff = (mating_pop[i] - mating_pop[i + 1]) / 2
168
+ offspring[i] = avg + beta * diff
169
+ offspring[i + 1] = avg - beta * diff
170
+
171
+ self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols)
172
+ self.notify()
173
+
174
+ return self.offspring_population
175
+
176
+ def update(self, *_, **__):
177
+ """Do nothing. This is just the basic SBX operator."""
178
+
179
+ def state(self) -> Sequence[Message]:
180
+ """Return the state of the crossover operator."""
181
+ if self.parent_population is None or self.offspring_population is None:
182
+ return []
183
+ if self.verbosity == 0:
184
+ return []
185
+ if self.verbosity == 1:
186
+ return [
187
+ FloatMessage(
188
+ topic=CrossoverMessageTopics.XOVER_PROBABILITY,
189
+ source="SimulatedBinaryCrossover",
190
+ value=self.xover_probability,
191
+ ),
192
+ FloatMessage(
193
+ topic=CrossoverMessageTopics.XOVER_DISTRIBUTION,
194
+ source="SimulatedBinaryCrossover",
195
+ value=self.xover_distribution,
196
+ ),
197
+ ]
198
+ # verbosity == 2 or higher
199
+ return [
200
+ FloatMessage(
201
+ topic=CrossoverMessageTopics.XOVER_PROBABILITY,
202
+ source="SimulatedBinaryCrossover",
203
+ value=self.xover_probability,
204
+ ),
205
+ FloatMessage(
206
+ topic=CrossoverMessageTopics.XOVER_DISTRIBUTION,
207
+ source="SimulatedBinaryCrossover",
208
+ value=self.xover_distribution,
209
+ ),
210
+ PolarsDataFrameMessage(
211
+ topic=CrossoverMessageTopics.PARENTS,
212
+ source="SimulatedBinaryCrossover",
213
+ value=self.parent_population,
214
+ ),
215
+ PolarsDataFrameMessage(
216
+ topic=CrossoverMessageTopics.OFFSPRINGS,
217
+ source="SimulatedBinaryCrossover",
218
+ value=self.offspring_population,
219
+ ),
220
+ ]
221
+
222
+
223
+ class SinglePointBinaryCrossover(BaseCrossover):
224
+ """A class that defines the single point binary crossover operation."""
225
+
226
+ def __init__(self, *, problem: Problem, seed: int, verbosity: int, publisher: Publisher):
227
+ """Initialize the single point binary crossover operator.
228
+
229
+ Args:
230
+ problem (Problem): the problem object.
231
+ seed (int): the seed used in the random number generator for choosing the crossover point.
232
+ verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
233
+ topics are provided by the operator at each verbosity level.
234
+ publisher (Publisher): the publisher to which the operator will publish messages.
235
+ """
236
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
237
+ self.seed = seed
238
+
239
+ self.parent_population: pl.DataFrame
240
+ self.offspring_population: pl.DataFrame
241
+ self.rng = np.random.default_rng(seed)
242
+ self.seed = seed
243
+
244
+ @property
245
+ def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
246
+ """The message topics provided by the single point binary crossover operator."""
247
+ return {
248
+ 0: [],
249
+ 1: [],
250
+ 2: [
251
+ CrossoverMessageTopics.PARENTS,
252
+ CrossoverMessageTopics.OFFSPRINGS,
253
+ ],
254
+ }
255
+
256
+ @property
257
+ def interested_topics(self):
258
+ """The message topics the single point binary crossover operator is interested in."""
259
+ return []
260
+
261
+ def do(
262
+ self,
263
+ *,
264
+ population: pl.DataFrame,
265
+ to_mate: list[int] | None = None,
266
+ ) -> pl.DataFrame:
267
+ """Perform single point binary crossover.
268
+
269
+ Args:
270
+ population (pl.DataFrame): the population to perform the crossover with.
271
+ to_mate (list[int] | None, optional): indices. Defaults to None.
272
+
273
+ Returns:
274
+ pl.DataFrame: the offspring from the crossover.
275
+ """
276
+ self.parent_population = population
277
+ pop_size = self.parent_population.shape[0]
278
+ num_var = len(self.variable_symbols)
279
+
280
+ parent_decision_vars = self.parent_population[self.variable_symbols].to_numpy().astype(np.bool)
281
+
282
+ if to_mate is None:
283
+ shuffled_ids = list(range(pop_size))
284
+ self.rng.shuffle(shuffled_ids)
285
+ else:
286
+ shuffled_ids = copy.copy(to_mate)
287
+
288
+ mating_pop = parent_decision_vars[shuffled_ids]
289
+ mating_pop_size = len(shuffled_ids)
290
+ original_mating_pop_size = mating_pop_size
291
+
292
+ if mating_pop_size % 2 != 0:
293
+ # if the number of member to mate is of uneven size, copy the first member to the tail
294
+ mating_pop = np.vstack((mating_pop, mating_pop[0]))
295
+ mating_pop_size += 1
296
+ shuffled_ids.append(shuffled_ids[0])
297
+
298
+ # split the population into parents, one with members with even numbered indices, the
299
+ # other with uneven numbered indices
300
+ parents1 = mating_pop[[shuffled_ids[i] for i in range(0, mating_pop_size, 2)]]
301
+ parents2 = mating_pop[[shuffled_ids[i] for i in range(1, mating_pop_size, 2)]]
302
+
303
+ cross_over_points = self.rng.integers(1, num_var - 1, mating_pop_size // 2)
304
+
305
+ # create a mask where, on each row, the element is 1 before the crossover point,
306
+ # and zero after it
307
+ cross_over_mask = np.zeros_like(parents1, dtype=np.bool)
308
+ cross_over_mask[np.arange(cross_over_mask.shape[1]) < cross_over_points[:, None]] = 1
309
+
310
+ # pick genes from the first parents before the crossover point
311
+ # pick genes from the second parents after, and including, the crossover point
312
+ offspring1_first = cross_over_mask & parents1
313
+ offspring1_second = (~cross_over_mask) & parents2
314
+
315
+ # combine into a first half of the whole offspring population
316
+ offspring1 = offspring1_first | offspring1_second
317
+
318
+ # pick genes from the first parents after, and including, the crossover point
319
+ # pick genes from the second parents before the crossover point
320
+ offspring2_first = (~cross_over_mask) & parents1
321
+ offspring2_second = cross_over_mask & parents2
322
+
323
+ # combine into the second half of the whole offspring population
324
+ offspring2 = offspring2_first | offspring2_second
325
+
326
+ # combine the two offspring populations into one, drop the last member if the number of
327
+ # indices (to_mate) is uneven
328
+ self.offspring_population = pl.from_numpy(
329
+ np.vstack((offspring1, offspring2))[
330
+ : (original_mating_pop_size if original_mating_pop_size % 2 == 0 else -1)
331
+ ],
332
+ schema=self.variable_symbols,
333
+ ).select(pl.all().cast(pl.Float64))
334
+ self.notify()
335
+
336
+ return self.offspring_population
337
+
338
+ def update(self, *_, **__):
339
+ """Do nothing. This is just the basic single point binary crossover operator."""
340
+
341
+ def state(self) -> Sequence[Message]:
342
+ """Return the state of the single ponit binary crossover operator."""
343
+ if self.parent_population is None or self.offspring_population is None:
344
+ return []
345
+ if self.verbosity == 0:
346
+ return []
347
+ if self.verbosity == 1:
348
+ return []
349
+ # verbosity == 2 or higher
350
+ return [
351
+ PolarsDataFrameMessage(
352
+ topic=CrossoverMessageTopics.PARENTS,
353
+ source="SimulatedBinaryCrossover",
354
+ value=self.parent_population,
355
+ ),
356
+ PolarsDataFrameMessage(
357
+ topic=CrossoverMessageTopics.OFFSPRINGS,
358
+ source="SimulatedBinaryCrossover",
359
+ value=self.offspring_population,
360
+ ),
361
+ ]
362
+
363
+
364
+ class UniformIntegerCrossover(BaseCrossover):
365
+ """A class that defines the uniform integer crossover operation."""
366
+
367
+ def __init__(self, *, problem: Problem, seed: int, verbosity: int, publisher: Publisher):
368
+ """Initialize the uniform integer crossover operator.
369
+
370
+ Args:
371
+ problem (Problem): the problem object.
372
+ seed (int): the seed used in the random number generator for choosing the crossover point.
373
+ verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
374
+ topics are provided by the operator at each verbosity level. Recommended to be set to 1.
375
+ publisher (Publisher): the publisher to which the operator will publish messages.
376
+ """
377
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
378
+ self.seed = seed
379
+
380
+ self.parent_population: pl.DataFrame
381
+ self.offspring_population: pl.DataFrame
382
+ self.rng = np.random.default_rng(seed)
383
+ self.seed = seed
384
+
385
+ @property
386
+ def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
387
+ """The message topics provided by the single point binary crossover operator."""
388
+ return {
389
+ 0: [],
390
+ 1: [],
391
+ 2: [
392
+ CrossoverMessageTopics.PARENTS,
393
+ CrossoverMessageTopics.OFFSPRINGS,
394
+ ],
395
+ }
396
+
397
+ @property
398
+ def interested_topics(self):
399
+ """The message topics the single point binary crossover operator is interested in."""
400
+ return []
401
+
402
+ def do(
403
+ self,
404
+ *,
405
+ population: pl.DataFrame,
406
+ to_mate: list[int] | None = None,
407
+ ) -> pl.DataFrame:
408
+ """Perform single point binary crossover.
409
+
410
+ Args:
411
+ population (pl.DataFrame): the population to perform the crossover with.
412
+ to_mate (list[int] | None, optional): indices. Defaults to None.
413
+
414
+ Returns:
415
+ pl.DataFrame: the offspring from the crossover.
416
+ """
417
+ self.parent_population = population
418
+ pop_size = self.parent_population.shape[0]
419
+ num_var = len(self.variable_symbols)
420
+
421
+ parent_decision_vars = self.parent_population[self.variable_symbols].to_numpy().astype(int)
422
+
423
+ if to_mate is None:
424
+ shuffled_ids = list(range(pop_size))
425
+ self.rng.shuffle(shuffled_ids)
426
+ else:
427
+ shuffled_ids = copy.copy(to_mate)
428
+
429
+ mating_pop = parent_decision_vars[shuffled_ids]
430
+ mating_pop_size = len(shuffled_ids)
431
+ original_mating_pop_size = mating_pop_size
432
+
433
+ if mating_pop_size % 2 != 0:
434
+ # if the number of member to mate is of uneven size, copy the first member to the tail
435
+ mating_pop = np.vstack((mating_pop, mating_pop[0]))
436
+ mating_pop_size += 1
437
+ shuffled_ids.append(shuffled_ids[0])
438
+
439
+ # split the population into parents, one with members with even numbered indices, the
440
+ # other with uneven numbered indices
441
+ parents1 = mating_pop[[shuffled_ids[i] for i in range(0, mating_pop_size, 2)]]
442
+ parents2 = mating_pop[[shuffled_ids[i] for i in range(1, mating_pop_size, 2)]]
443
+
444
+ mask = self.rng.choice([True, False], size=num_var)
445
+
446
+ offspring1 = np.where(mask, parents1, parents2) # True, pick from parent1, False, pick from parent2
447
+ offspring2 = np.where(mask, parents2, parents1) # True, pick from parent2, False, pick from parent1
448
+
449
+ # combine the two offspring populations into one, drop the last member if the number of
450
+ # indices (to_mate) is uneven
451
+ self.offspring_population = pl.from_numpy(
452
+ np.vstack((offspring1, offspring2))[
453
+ : (original_mating_pop_size if original_mating_pop_size % 2 == 0 else -1)
454
+ ],
455
+ schema=self.variable_symbols,
456
+ ).select(pl.all().cast(pl.Float64))
457
+
458
+ self.notify()
459
+
460
+ return self.offspring_population
461
+
462
+ def update(self, *_, **__):
463
+ """Do nothing. This is just the basic single point binary crossover operator."""
464
+
465
+ def state(self) -> Sequence[Message]:
466
+ """Return the state of the single ponit binary crossover operator."""
467
+ if self.parent_population is None or self.offspring_population is None:
468
+ return []
469
+ if self.verbosity == 0:
470
+ return []
471
+ if self.verbosity == 1:
472
+ return []
473
+ # verbosity == 2 or higher
474
+ return [
475
+ PolarsDataFrameMessage(
476
+ topic=CrossoverMessageTopics.PARENTS,
477
+ source="SimulatedBinaryCrossover",
478
+ value=self.parent_population,
479
+ ),
480
+ PolarsDataFrameMessage(
481
+ topic=CrossoverMessageTopics.OFFSPRINGS,
482
+ source="SimulatedBinaryCrossover",
483
+ value=self.offspring_population,
484
+ ),
485
+ ]
486
+
487
+
488
+ class UniformMixedIntegerCrossover(BaseCrossover):
489
+ """A class that defines the uniform mixed-integer crossover operation.
490
+
491
+ TODO: This is virtually identical to `UniformIntegerCrossover`. The only
492
+ difference is that the `parent_decision_vars` in `do` are not casted to
493
+ `int`. This is not an ideal way to implement crossover for mixed-integer
494
+ stuff...
495
+ """
496
+
497
+ def __init__(self, *, problem: Problem, seed: int, verbosity: int, publisher: Publisher):
498
+ """Initialize the uniform integer crossover operator.
499
+
500
+ Args:
501
+ problem (Problem): the problem object.
502
+ seed (int): the seed used in the random number generator for choosing the crossover point.
503
+ verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
504
+ topics are provided by the operator at each verbosity level. Recommended to be set to 1.
505
+ publisher (Publisher): the publisher to which the operator will publish messages.
506
+ """
507
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
508
+ self.seed = seed
509
+
510
+ self.parent_population: pl.DataFrame
511
+ self.offspring_population: pl.DataFrame
512
+ self.rng = np.random.default_rng(seed)
513
+ self.seed = seed
514
+
515
+ @property
516
+ def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
517
+ """The message topics provided by the single point binary crossover operator."""
518
+ return {
519
+ 0: [],
520
+ 1: [],
521
+ 2: [
522
+ CrossoverMessageTopics.PARENTS,
523
+ CrossoverMessageTopics.OFFSPRINGS,
524
+ ],
525
+ }
526
+
527
+ @property
528
+ def interested_topics(self):
529
+ """The message topics the single point binary crossover operator is interested in."""
530
+ return []
531
+
532
+ def do(
533
+ self,
534
+ *,
535
+ population: pl.DataFrame,
536
+ to_mate: list[int] | None = None,
537
+ ) -> pl.DataFrame:
538
+ """Perform single point binary crossover.
539
+
540
+ Args:
541
+ population (pl.DataFrame): the population to perform the crossover with.
542
+ to_mate (list[int] | None, optional): indices. Defaults to None.
543
+
544
+ Returns:
545
+ pl.DataFrame: the offspring from the crossover.
546
+ """
547
+ self.parent_population = population
548
+ pop_size = self.parent_population.shape[0]
549
+ num_var = len(self.variable_symbols)
550
+
551
+ parent_decision_vars = self.parent_population[self.variable_symbols].to_numpy().astype(float)
552
+
553
+ if to_mate is None:
554
+ shuffled_ids = list(range(pop_size))
555
+ self.rng.shuffle(shuffled_ids)
556
+ else:
557
+ shuffled_ids = copy.copy(to_mate)
558
+
559
+ mating_pop = parent_decision_vars[shuffled_ids]
560
+ mating_pop_size = len(shuffled_ids)
561
+ original_mating_pop_size = mating_pop_size
562
+
563
+ if mating_pop_size % 2 != 0:
564
+ # if the number of member to mate is of uneven size, copy the first member to the tail
565
+ mating_pop = np.vstack((mating_pop, mating_pop[0]))
566
+ mating_pop_size += 1
567
+ shuffled_ids.append(shuffled_ids[0])
568
+
569
+ # split the population into parents, one with members with even numbered indices, the
570
+ # other with uneven numbered indices
571
+ parents1 = mating_pop[[shuffled_ids[i] for i in range(0, mating_pop_size, 2)]]
572
+ parents2 = mating_pop[[shuffled_ids[i] for i in range(1, mating_pop_size, 2)]]
573
+
574
+ mask = self.rng.choice([True, False], size=num_var)
575
+
576
+ offspring1 = np.where(mask, parents1, parents2) # True, pick from parent1, False, pick from parent2
577
+ offspring2 = np.where(mask, parents2, parents1) # True, pick from parent2, False, pick from parent1
578
+
579
+ # combine the two offspring populations into one, drop the last member if the number of
580
+ # indices (to_mate) is uneven
581
+ self.offspring_population = pl.from_numpy(
582
+ np.vstack((offspring1, offspring2))[
583
+ : (original_mating_pop_size if original_mating_pop_size % 2 == 0 else -1)
584
+ ],
585
+ schema=self.variable_symbols,
586
+ ).select(pl.all().cast(pl.Float64))
587
+
588
+ self.notify()
589
+
590
+ return self.offspring_population
591
+
592
+ def update(self, *_, **__):
593
+ """Do nothing. This is just the basic single point binary crossover operator."""
594
+
595
+ def state(self) -> Sequence[Message]:
596
+ """Return the state of the single point binary crossover operator."""
597
+ if self.parent_population is None or self.offspring_population is None:
598
+ return []
599
+ if self.verbosity == 0:
600
+ return []
601
+ if self.verbosity == 1:
602
+ return []
603
+ # verbosity == 2 or higher
604
+ return [
605
+ PolarsDataFrameMessage(
606
+ topic=CrossoverMessageTopics.PARENTS,
607
+ source="SimulatedBinaryCrossover",
608
+ value=self.parent_population,
609
+ ),
610
+ PolarsDataFrameMessage(
611
+ topic=CrossoverMessageTopics.OFFSPRINGS,
612
+ source="SimulatedBinaryCrossover",
613
+ value=self.offspring_population,
614
+ ),
615
+ ]
616
+
617
+
618
+ class BlendAlphaCrossover(BaseCrossover):
619
+ """Blend-alpha (BLX-alpha) crossover for continuous problems."""
620
+
621
+ @property
622
+ def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
623
+ """The message topics provided by the blend alpha crossover operator."""
624
+ return {
625
+ 0: [],
626
+ 1: [
627
+ CrossoverMessageTopics.XOVER_PROBABILITY,
628
+ CrossoverMessageTopics.ALPHA,
629
+ ],
630
+ 2: [
631
+ CrossoverMessageTopics.XOVER_PROBABILITY,
632
+ CrossoverMessageTopics.ALPHA,
633
+ CrossoverMessageTopics.PARENTS,
634
+ CrossoverMessageTopics.OFFSPRINGS,
635
+ ],
636
+ }
637
+
638
+ @property
639
+ def interested_topics(self):
640
+ """The message topics provided by the blend alpha crossover operator."""
641
+ return []
642
+
643
+ def __init__(
644
+ self,
645
+ *,
646
+ problem: Problem,
647
+ verbosity: int,
648
+ publisher: Publisher,
649
+ seed: int,
650
+ alpha: float = 0.5,
651
+ xover_probability: float = 1.0,
652
+ ):
653
+ """Initialize the blend alpha crossover operator.
654
+
655
+ Args:
656
+ problem (Problem): the problem object.
657
+ verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
658
+ topics are provided by the operator at each verbosity level. Recommended to be set to 1.
659
+ publisher (Publisher): the publisher to which the operator will publish messages.
660
+ seed (int): the seed used in the random number generator for choosing the crossover point.
661
+ alpha (float, optional): non-negative blending factor 'alpha' that controls the extent to which
662
+ offspring may be sampled outside the interval defined by each pair of parent
663
+ genes. alpha = 0 restricts children strictly within the
664
+ parents range, larger alpha allows some outliers. Defaults to 0.5.
665
+ xover_probability (float, optional): the crossover probability parameter.
666
+ Ranges between 0 and 1.0. Defaults to 1.0.
667
+ """
668
+ super().__init__(problem=problem, verbosity=verbosity, publisher=publisher)
669
+
670
+ if problem.variable_domain is not VariableDomainTypeEnum.continuous:
671
+ raise ValueError("BlendAlphaCrossover only works on continuous problems.")
672
+
673
+ if not 0 <= xover_probability <= 1:
674
+ raise ValueError("Crossover probability must be in [0,1].")
675
+ if alpha < 0:
676
+ raise ValueError("Alpha must be non-negative.")
677
+
678
+ self.alpha = alpha
679
+ self.xover_probability = xover_probability
680
+ self.seed = seed
681
+ self.rng = np.random.default_rng(self.seed)
682
+
683
+ self.parent_population: pl.DataFrame | None = None
684
+ self.offspring_population: pl.DataFrame | None = None
685
+
686
+ def do(
687
+ self,
688
+ *,
689
+ population: pl.DataFrame,
690
+ to_mate: list[int] | None = None,
691
+ ) -> pl.DataFrame:
692
+ """Perform BLX-alpha crossover.
693
+
694
+ Args:
695
+ population (pl.DataFrame): the population to perform the crossover with. The DataFrame
696
+ contains the decision vectors, the target vectors, and the constraint vectors.
697
+ to_mate (list[int] | None): the indices of the population members that should
698
+ participate in the crossover. If `None`, the whole population is subject
699
+ to the crossover.
700
+
701
+ Returns:
702
+ pl.DataFrame: the offspring resulting from the crossover.
703
+ """
704
+ self.parent_population = population
705
+ pop_size = population.shape[0]
706
+ num_var = len(self.variable_symbols)
707
+
708
+ parent_decision_vars = population[self.variable_symbols].to_numpy()
709
+ if to_mate is None:
710
+ shuffled_ids = list(range(pop_size))
711
+ self.rng.shuffle(shuffled_ids)
712
+ else:
713
+ shuffled_ids = copy.copy(to_mate)
714
+
715
+ mating_pop_size = len(shuffled_ids)
716
+ original_pop_size = mating_pop_size
717
+ if mating_pop_size % 2 == 1:
718
+ shuffled_ids.append(shuffled_ids[0])
719
+ mating_pop_size += 1
720
+
721
+ mating_pop = parent_decision_vars[shuffled_ids]
722
+
723
+ parents1 = mating_pop[0::2, :]
724
+ parents2 = mating_pop[1::2, :]
725
+
726
+ c_min = np.array(self.lower_bounds)
727
+ c_max = np.array(self.upper_bounds)
728
+ span = c_max - c_min
729
+
730
+ lower = c_min - self.alpha * span
731
+ upper = c_max + self.alpha * span
732
+
733
+ uniform_1 = self.rng.random((mating_pop_size // 2, num_var))
734
+ uniform_2 = self.rng.random((mating_pop_size // 2, num_var))
735
+
736
+ offspring1 = lower + uniform_1 * (upper - lower)
737
+ offspring2 = lower + uniform_2 * (upper - lower)
738
+
739
+ mask = self.rng.random(mating_pop_size // 2) > self.xover_probability
740
+ offspring1[mask, :] = parents1[mask, :]
741
+ offspring2[mask, :] = parents2[mask, :]
742
+
743
+ offspring = np.vstack((offspring1, offspring2))
744
+ if original_pop_size % 2 == 1:
745
+ offspring = offspring[:-1, :]
746
+
747
+ self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols).select(
748
+ pl.all().cast(pl.Float64)
749
+ )
750
+ self.notify()
751
+ return self.offspring_population
752
+
753
+ def update(self, *_, **__):
754
+ """Do nothing."""
755
+
756
+ def state(self) -> Sequence[Message]:
757
+ """Return the state of the blend-alpha crossover operator."""
758
+ if self.parent_population is None:
759
+ return []
760
+ msgs: list[Message] = []
761
+ if self.verbosity >= 1:
762
+ msgs.append(
763
+ FloatMessage(
764
+ topic=CrossoverMessageTopics.XOVER_PROBABILITY,
765
+ source=self.__class__.__name__,
766
+ value=self.xover_probability,
767
+ )
768
+ )
769
+ msgs.append(
770
+ FloatMessage(
771
+ topic=CrossoverMessageTopics.ALPHA,
772
+ source=self.__class__.__name__,
773
+ value=self.alpha,
774
+ )
775
+ )
776
+ if self.verbosity >= 2: # noqa: PLR2004
777
+ msgs.extend(
778
+ [
779
+ PolarsDataFrameMessage(
780
+ topic=CrossoverMessageTopics.PARENTS,
781
+ source=self.__class__.__name__,
782
+ value=self.parent_population,
783
+ ),
784
+ PolarsDataFrameMessage(
785
+ topic=CrossoverMessageTopics.OFFSPRINGS,
786
+ source=self.__class__.__name__,
787
+ value=self.offspring_population,
788
+ ),
789
+ ]
790
+ )
791
+ return msgs
792
+
793
+
794
+ class SingleArithmeticCrossover(BaseCrossover):
795
+ """Single Arithmetic Crossover for continuous problems."""
796
+
797
+ @property
798
+ def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
799
+ """The message topics provided by the single arithmetic crossover operator."""
800
+ return {
801
+ 0: [], # No topics for 0
802
+ 1: [
803
+ CrossoverMessageTopics.XOVER_PROBABILITY, # Probability of crossover
804
+ ],
805
+ 2: [
806
+ CrossoverMessageTopics.XOVER_PROBABILITY, # Crossover probability
807
+ CrossoverMessageTopics.PARENTS, # Parents involved in crossover
808
+ CrossoverMessageTopics.OFFSPRINGS, # Offsprings created from crossover
809
+ ],
810
+ }
811
+
812
+ @property
813
+ def interested_topics(self):
814
+ """The message topics that the single arithmetic crossover operator is interested in."""
815
+ return []
816
+
817
+ def __init__(
818
+ self,
819
+ problem: Problem,
820
+ verbosity: int,
821
+ publisher: Publisher,
822
+ seed: int,
823
+ xover_probability: float = 1.0,
824
+ ):
825
+ """Initialize the single arithmetic crossover operator.
826
+
827
+ Args:
828
+ problem (Problem): the problem object.
829
+ verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
830
+ topics are provided by the operator at each verbosity level. Recommended to be set to 1.
831
+ publisher (Publisher): the publisher to which the operator will publish messages.
832
+ xover_probability (float): probability of performing crossover.
833
+ seed (int): random seed for reproducibility.
834
+ """
835
+ super().__init__(problem=problem, verbosity=verbosity, publisher=publisher)
836
+
837
+ if not 0 <= xover_probability <= 1:
838
+ raise ValueError("Crossover probability must be in [0, 1].")
839
+
840
+ self.xover_probability = xover_probability
841
+ self.seed = seed
842
+ self.parent_population: pl.DataFrame | None = None
843
+ self.offspring_population: pl.DataFrame | None = None
844
+ self.rng = np.random.default_rng(self.seed)
845
+
846
+ def do(self, *, population: pl.DataFrame, to_mate: list[int] | None = None) -> pl.DataFrame:
847
+ """Perform Single Arithmetic Crossover.
848
+
849
+ Args:
850
+ population (pl.DataFrame): the population to perform the crossover with. The DataFrame
851
+ contains the decision vectors, the target vectors, and the constraint vectors.
852
+ to_mate (list[int] | None): the indices of the population members that should
853
+ participate in the crossover. If `None`, the whole population is subject
854
+ to the crossover.
855
+
856
+ Returns:
857
+ pl.DataFrame: the offspring resulting from the crossover.
858
+ """
859
+ self.parent_population = population
860
+ pop_size = population.shape[0]
861
+ num_vars = len(self.variable_symbols)
862
+
863
+ parents = population[self.variable_symbols].to_numpy()
864
+
865
+ if to_mate is None:
866
+ mating_indices = list(range(pop_size))
867
+ self.rng.shuffle(mating_indices)
868
+ else:
869
+ mating_indices = copy.copy(to_mate)
870
+
871
+ mating_pop_size = len(mating_indices)
872
+ original_pop_size = mating_pop_size
873
+
874
+ if mating_pop_size % 2 == 1:
875
+ mating_indices.append(mating_indices[0])
876
+ mating_pop_size += 1
877
+
878
+ mating_pool = parents[mating_indices, :]
879
+
880
+ parents1 = mating_pool[0::2, :]
881
+ parents2 = mating_pool[1::2, :]
882
+
883
+ mask = self.rng.random(mating_pop_size // 2) <= self.xover_probability
884
+ gene_pos = self.rng.integers(0, num_vars, size=mating_pop_size // 2)
885
+
886
+ # Initialize offspring as exact copies
887
+ offspring1 = parents1.copy()
888
+ offspring2 = parents2.copy()
889
+
890
+ # Apply crossover only for selected pairs
891
+ row_idx = np.arange(len(mask))[mask]
892
+ col_idx = gene_pos[mask]
893
+
894
+ avg = 0.5 * (parents1[row_idx, col_idx] + parents2[row_idx, col_idx])
895
+
896
+ # Use advanced indexing to set arithmetic crossover gene
897
+ offspring1[row_idx, col_idx] = avg
898
+ offspring2[row_idx, col_idx] = avg
899
+
900
+ for i, k in zip(row_idx, col_idx, strict=True):
901
+ offspring1[i, k + 1 :] = parents2[i, k + 1 :]
902
+ offspring2[i, k + 1 :] = parents1[i, k + 1 :]
903
+ offspring1[i, :k] = parents1[i, :k]
904
+ offspring2[i, :k] = parents2[i, :k]
905
+
906
+ offspring = np.vstack((offspring1, offspring2))
907
+ if original_pop_size % 2 == 1:
908
+ offspring = offspring[:-1, :]
909
+
910
+ self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols).select(
911
+ pl.all().cast(pl.Float64)
912
+ )
913
+ self.notify()
914
+ return self.offspring_population
915
+
916
+ def update(self, *_, **__):
917
+ """Do nothing."""
918
+
919
+ def state(self) -> Sequence[Message]:
920
+ """Return the state of the single arithmetic crossover operator."""
921
+ if self.parent_population is None:
922
+ return []
923
+
924
+ msgs: list[Message] = []
925
+
926
+ # Messages for crossover probability
927
+ if self.verbosity >= 1:
928
+ msgs.append(
929
+ FloatMessage(
930
+ topic=CrossoverMessageTopics.XOVER_PROBABILITY,
931
+ source=self.__class__.__name__,
932
+ value=self.xover_probability,
933
+ )
934
+ )
935
+
936
+ # Messages for parents and offspring
937
+ if self.verbosity >= 2: # More detailed info
938
+ msgs.extend(
939
+ [
940
+ PolarsDataFrameMessage(
941
+ topic=CrossoverMessageTopics.PARENTS,
942
+ source=self.__class__.__name__,
943
+ value=self.parent_population,
944
+ ),
945
+ PolarsDataFrameMessage(
946
+ topic=CrossoverMessageTopics.OFFSPRINGS,
947
+ source=self.__class__.__name__,
948
+ value=self.offspring_population,
949
+ ),
950
+ ]
951
+ )
952
+
953
+ return msgs
954
+
955
+
956
+ class LocalCrossover(BaseCrossover):
957
+ """Local Crossover for continuous problems."""
958
+
959
+ @property
960
+ def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
961
+ """The message topics provided by the local crossover operator."""
962
+ return {
963
+ 0: [],
964
+ 1: [
965
+ CrossoverMessageTopics.XOVER_PROBABILITY,
966
+ ],
967
+ 2: [
968
+ CrossoverMessageTopics.XOVER_PROBABILITY,
969
+ CrossoverMessageTopics.PARENTS,
970
+ CrossoverMessageTopics.OFFSPRINGS,
971
+ ],
972
+ }
973
+
974
+ @property
975
+ def interested_topics(self):
976
+ """The message topics that the local crossover operator is interested in."""
977
+ return []
978
+
979
+ def __init__(
980
+ self,
981
+ problem: Problem,
982
+ verbosity: int,
983
+ publisher: Publisher,
984
+ seed: int,
985
+ xover_probability: float = 1.0,
986
+ ):
987
+ """Initialize the local crossover operator.
988
+
989
+ Args:
990
+ problem (Problem): the problem object.
991
+ verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
992
+ topics are provided by the operator at each verbosity level. Recommended to be set to 1.
993
+ publisher (Publisher): the publisher to which the operator will publish messages.
994
+ xover_probability (float): probability of performing crossover.
995
+ seed (int): random seed for reproducibility.
996
+ """
997
+ super().__init__(problem=problem, verbosity=verbosity, publisher=publisher)
998
+
999
+ if not 0 <= xover_probability <= 1:
1000
+ raise ValueError("Crossover probability must be in [0, 1].")
1001
+
1002
+ self.xover_probability = xover_probability
1003
+ self.seed = seed
1004
+ self.rng = np.random.default_rng(self.seed)
1005
+ self.parent_population: pl.DataFrame | None = None
1006
+ self.offspring_population: pl.DataFrame | None = None
1007
+
1008
+ def do(self, *, population: pl.DataFrame, to_mate: list[int] | None = None) -> pl.DataFrame:
1009
+ """Perform Local Crossover.
1010
+
1011
+ Args:
1012
+ population (pl.DataFrame): the population to perform the crossover with. The DataFrame
1013
+ contains the decision vectors, the target vectors, and the constraint vectors.
1014
+ to_mate (list[int] | None): the indices of the population members that should
1015
+ participate in the crossover. If `None`, the whole population is subject
1016
+ to the crossover.
1017
+
1018
+ Returns:
1019
+ pl.DataFrame: the offspring resulting from the crossover.
1020
+ """
1021
+ self.parent_population = population
1022
+ pop_size = population.shape[0]
1023
+ num_var = len(self.variable_symbols)
1024
+
1025
+ parent_decision_vars = population[self.variable_symbols].to_numpy()
1026
+
1027
+ if to_mate is None:
1028
+ shuffled_ids = list(range(pop_size))
1029
+ self.rng.shuffle(shuffled_ids)
1030
+ else:
1031
+ shuffled_ids = to_mate.copy()
1032
+
1033
+ mating_pop_size = len(shuffled_ids)
1034
+ if mating_pop_size % 2 == 1:
1035
+ shuffled_ids.append(shuffled_ids[0])
1036
+ mating_pop_size += 1
1037
+
1038
+ mating_pop = parent_decision_vars[shuffled_ids]
1039
+ parents1 = mating_pop[0::2]
1040
+ parents2 = mating_pop[1::2]
1041
+
1042
+ offspring = np.empty((mating_pop_size, num_var))
1043
+
1044
+ for i in range(mating_pop_size // 2):
1045
+ if self.rng.random() < self.xover_probability:
1046
+ alpha = self.rng.random(num_var)
1047
+
1048
+ offspring[2 * i] = alpha * parents1[i] + (1 - alpha) * parents2[i]
1049
+ offspring[2 * i + 1] = (1 - alpha) * parents1[i] + alpha * parents2[i]
1050
+ else:
1051
+ offspring[2 * i] = parents1[i]
1052
+ offspring[2 * i + 1] = parents2[i]
1053
+
1054
+ self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols).select(
1055
+ pl.all().cast(pl.Float64)
1056
+ )
1057
+
1058
+ self.notify()
1059
+ return self.offspring_population
1060
+
1061
+ def update(self, *_, **__):
1062
+ """Do nothing."""
1063
+
1064
+ def state(self) -> Sequence[Message]:
1065
+ """Return the state of the local crossover operator."""
1066
+ if self.parent_population is None:
1067
+ return []
1068
+
1069
+ msgs: list[Message] = []
1070
+
1071
+ if self.verbosity >= 1:
1072
+ msgs.append(
1073
+ FloatMessage(
1074
+ topic=CrossoverMessageTopics.XOVER_PROBABILITY,
1075
+ source=self.__class__.__name__,
1076
+ value=self.xover_probability,
1077
+ )
1078
+ )
1079
+ if self.verbosity >= 2:
1080
+ msgs.extend(
1081
+ [
1082
+ PolarsDataFrameMessage(
1083
+ topic=CrossoverMessageTopics.PARENTS,
1084
+ source=self.__class__.__name__,
1085
+ value=self.parent_population,
1086
+ ),
1087
+ PolarsDataFrameMessage(
1088
+ topic=CrossoverMessageTopics.OFFSPRINGS,
1089
+ source=self.__class__.__name__,
1090
+ value=self.offspring_population,
1091
+ ),
1092
+ ]
1093
+ )
1094
+ return msgs
1095
+
1096
+
1097
+ class BoundedExponentialCrossover(BaseCrossover):
1098
+ """Bounded‐exponential (BEX) crossover for continuous problems."""
1099
+
1100
+ @property
1101
+ def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
1102
+ """The message topics provided by the bounded exponential crossover operator."""
1103
+ return {
1104
+ 0: [],
1105
+ 1: [
1106
+ CrossoverMessageTopics.XOVER_PROBABILITY,
1107
+ CrossoverMessageTopics.LAMBDA,
1108
+ ],
1109
+ 2: [
1110
+ CrossoverMessageTopics.XOVER_PROBABILITY,
1111
+ CrossoverMessageTopics.LAMBDA,
1112
+ CrossoverMessageTopics.PARENTS,
1113
+ CrossoverMessageTopics.OFFSPRINGS,
1114
+ ],
1115
+ }
1116
+
1117
+ @property
1118
+ def interested_topics(self):
1119
+ """The message topics provided by the bounded exponential crossover operator."""
1120
+ return []
1121
+
1122
+ def __init__(
1123
+ self,
1124
+ *,
1125
+ problem: Problem,
1126
+ verbosity: int,
1127
+ publisher: Publisher,
1128
+ seed: int,
1129
+ lambda_: float = 1.0,
1130
+ xover_probability: float = 1.0,
1131
+ ):
1132
+ """Initialize the bounded‐exponential crossover operator.
1133
+
1134
+ Args:
1135
+ problem (Problem): the problem object.
1136
+ verbosity (int): the verbosity level of the component. The keys in `provided_topics` tell what
1137
+ topics are provided by the operator at each verbosity level. Recommended to be set to 1.
1138
+ publisher (Publisher): the publisher to which the operator will publish messages.
1139
+ seed (int): random seed for the internal generator.
1140
+ lambda_ (float, optional): positive scale λ for the exponential distribution.
1141
+ Defaults to 1.0.
1142
+ xover_probability (float, optional): probability of applying crossover
1143
+ to each pair. Defaults to 1.0.
1144
+ """
1145
+ super().__init__(problem=problem, verbosity=verbosity, publisher=publisher)
1146
+
1147
+ if problem.variable_domain is not VariableDomainTypeEnum.continuous:
1148
+ raise ValueError("BoundedExponentialCrossover only works on continuous problems.")
1149
+ if lambda_ <= 0:
1150
+ raise ValueError("lambda_ must be positive.")
1151
+ if not 0 <= xover_probability <= 1:
1152
+ raise ValueError("xover_probability must be in [0,1].")
1153
+
1154
+ self.lambda_ = lambda_
1155
+ self.xover_probability = xover_probability
1156
+ self.seed = seed
1157
+ self.rng = np.random.default_rng(self.seed)
1158
+
1159
+ self.parent_population: pl.DataFrame | None = None
1160
+ self.offspring_population: pl.DataFrame | None = None
1161
+
1162
+ def do(
1163
+ self,
1164
+ *,
1165
+ population: pl.DataFrame,
1166
+ to_mate: list[int] | None = None,
1167
+ ) -> pl.DataFrame:
1168
+ """Perform bounded‐exponential crossover.
1169
+
1170
+ Args:
1171
+ population (pl.DataFrame): the population to perform the crossover with. The DataFrame
1172
+ contains the decision vectors, the target vectors, and the constraint vectors.
1173
+ to_mate (list[int] | None): the indices of the population members that should
1174
+ participate in the crossover. If `None`, the whole population is subject
1175
+ to the crossover.
1176
+
1177
+ Returns:
1178
+ pl.DataFrame: the offspring resulting from the crossover.
1179
+ """
1180
+ self.parent_population = population
1181
+ pop_size = population.shape[0]
1182
+ num_var = len(self.variable_symbols)
1183
+
1184
+ parent_decision_vars = population[self.variable_symbols].to_numpy()
1185
+ if to_mate is None:
1186
+ shuffled_ids = list(range(pop_size))
1187
+ self.rng.shuffle(shuffled_ids)
1188
+ else:
1189
+ shuffled_ids = copy.copy(to_mate)
1190
+
1191
+ mating_pop_size = len(shuffled_ids)
1192
+ original_pop_size = mating_pop_size
1193
+ if mating_pop_size % 2 == 1:
1194
+ shuffled_ids.append(shuffled_ids[0])
1195
+ mating_pop_size += 1
1196
+
1197
+ mating_pop = parent_decision_vars[shuffled_ids]
1198
+
1199
+ parents1 = mating_pop[0::2, :]
1200
+ parents2 = mating_pop[1::2, :]
1201
+
1202
+ x_lower = np.array(self.lower_bounds)
1203
+ x_upper = np.array(self.upper_bounds)
1204
+ span = parents2 - parents1 # y_i - x_1
1205
+
1206
+ u_i = self.rng.random((mating_pop_size // 2, num_var)) # random integers
1207
+ r_i = self.rng.random((mating_pop_size // 2, num_var))
1208
+
1209
+ exp_lower_1 = np.exp((x_lower - parents1) / (self.lambda_ * span))
1210
+ exp_upper_1 = np.exp((parents1 - x_upper) / (self.lambda_ * span))
1211
+
1212
+ exp_lower_2 = np.exp((x_lower - parents2) / (self.lambda_ * span))
1213
+ exp_upper_2 = np.exp((parents2 - x_upper) / (self.lambda_ * span))
1214
+
1215
+ beta_1 = np.where(
1216
+ r_i <= 0.5,
1217
+ self.lambda_ * np.log(exp_lower_1 + u_i * (1 - exp_lower_1)),
1218
+ -self.lambda_ * np.log(1 - u_i * (1 - exp_upper_1)),
1219
+ )
1220
+
1221
+ beta_2 = np.where(
1222
+ r_i <= 0.5,
1223
+ self.lambda_ * np.log(exp_lower_2 + u_i * (1 - exp_lower_2)),
1224
+ -self.lambda_ * np.log(1 - u_i * (1 - exp_upper_2)),
1225
+ )
1226
+
1227
+ offspring1 = parents1 + beta_1 * span
1228
+ offspring2 = parents2 + beta_2 * span
1229
+
1230
+ mask = self.rng.random(mating_pop_size // 2) > self.xover_probability
1231
+ offspring1[mask, :] = parents1[mask, :]
1232
+ offspring2[mask, :] = parents2[mask, :]
1233
+
1234
+ children = np.vstack((offspring1, offspring2))
1235
+ if original_pop_size % 2 == 1:
1236
+ children = children[:-1, :]
1237
+
1238
+ self.offspring_population = pl.from_numpy(children, schema=self.variable_symbols).select(
1239
+ pl.all().cast(pl.Float64)
1240
+ )
1241
+ self.notify()
1242
+ return self.offspring_population
1243
+
1244
+ def update(self, *_, **__):
1245
+ """Do nothing."""
1246
+
1247
+ def state(self) -> Sequence[Message]:
1248
+ """Return the state of the crossover operator."""
1249
+ if getattr(self, "parent_population", None) is None:
1250
+ return []
1251
+ msgs: list[Message] = []
1252
+ if self.verbosity >= 1:
1253
+ msgs.append(
1254
+ FloatMessage(
1255
+ topic=CrossoverMessageTopics.XOVER_PROBABILITY,
1256
+ source=self.__class__.__name__,
1257
+ value=self.xover_probability,
1258
+ )
1259
+ )
1260
+ msgs.append(
1261
+ FloatMessage(
1262
+ topic=CrossoverMessageTopics.LAMBDA,
1263
+ source=self.__class__.__name__,
1264
+ value=self.lambda_,
1265
+ )
1266
+ )
1267
+ if self.verbosity >= 2:
1268
+ msgs.extend(
1269
+ [
1270
+ PolarsDataFrameMessage(
1271
+ topic=CrossoverMessageTopics.PARENTS,
1272
+ source=self.__class__.__name__,
1273
+ value=self.parent_population,
1274
+ ),
1275
+ PolarsDataFrameMessage(
1276
+ topic=CrossoverMessageTopics.OFFSPRINGS,
1277
+ source=self.__class__.__name__,
1278
+ value=self.offspring_population,
1279
+ ),
1280
+ ]
1281
+ )
1282
+ return msgs