desdeo 1.1.3__py3-none-any.whl → 2.0.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 (122) hide show
  1. desdeo/__init__.py +8 -8
  2. desdeo/api/README.md +73 -0
  3. desdeo/api/__init__.py +15 -0
  4. desdeo/api/app.py +40 -0
  5. desdeo/api/config.py +69 -0
  6. desdeo/api/config.toml +53 -0
  7. desdeo/api/db.py +25 -0
  8. desdeo/api/db_init.py +79 -0
  9. desdeo/api/db_models.py +164 -0
  10. desdeo/api/malaga_db_init.py +27 -0
  11. desdeo/api/models/__init__.py +66 -0
  12. desdeo/api/models/archive.py +34 -0
  13. desdeo/api/models/preference.py +90 -0
  14. desdeo/api/models/problem.py +507 -0
  15. desdeo/api/models/reference_point_method.py +18 -0
  16. desdeo/api/models/session.py +46 -0
  17. desdeo/api/models/state.py +96 -0
  18. desdeo/api/models/user.py +51 -0
  19. desdeo/api/routers/_NAUTILUS.py +245 -0
  20. desdeo/api/routers/_NAUTILUS_navigator.py +233 -0
  21. desdeo/api/routers/_NIMBUS.py +762 -0
  22. desdeo/api/routers/__init__.py +5 -0
  23. desdeo/api/routers/problem.py +110 -0
  24. desdeo/api/routers/reference_point_method.py +117 -0
  25. desdeo/api/routers/session.py +76 -0
  26. desdeo/api/routers/test.py +16 -0
  27. desdeo/api/routers/user_authentication.py +366 -0
  28. desdeo/api/schema.py +94 -0
  29. desdeo/api/tests/__init__.py +0 -0
  30. desdeo/api/tests/conftest.py +59 -0
  31. desdeo/api/tests/test_models.py +701 -0
  32. desdeo/api/tests/test_routes.py +216 -0
  33. desdeo/api/utils/database.py +274 -0
  34. desdeo/api/utils/logger.py +29 -0
  35. desdeo/core.py +27 -0
  36. desdeo/emo/__init__.py +29 -0
  37. desdeo/emo/hooks/archivers.py +172 -0
  38. desdeo/emo/methods/EAs.py +418 -0
  39. desdeo/emo/methods/__init__.py +0 -0
  40. desdeo/emo/methods/bases.py +59 -0
  41. desdeo/emo/operators/__init__.py +1 -0
  42. desdeo/emo/operators/crossover.py +780 -0
  43. desdeo/emo/operators/evaluator.py +118 -0
  44. desdeo/emo/operators/generator.py +356 -0
  45. desdeo/emo/operators/mutation.py +1053 -0
  46. desdeo/emo/operators/selection.py +1036 -0
  47. desdeo/emo/operators/termination.py +178 -0
  48. desdeo/explanations/__init__.py +6 -0
  49. desdeo/explanations/explainer.py +100 -0
  50. desdeo/explanations/utils.py +90 -0
  51. desdeo/mcdm/__init__.py +19 -0
  52. desdeo/mcdm/nautili.py +345 -0
  53. desdeo/mcdm/nautilus.py +477 -0
  54. desdeo/mcdm/nautilus_navigator.py +655 -0
  55. desdeo/mcdm/nimbus.py +417 -0
  56. desdeo/mcdm/pareto_navigator.py +269 -0
  57. desdeo/mcdm/reference_point_method.py +116 -0
  58. desdeo/problem/__init__.py +79 -0
  59. desdeo/problem/evaluator.py +561 -0
  60. desdeo/problem/gurobipy_evaluator.py +562 -0
  61. desdeo/problem/infix_parser.py +341 -0
  62. desdeo/problem/json_parser.py +944 -0
  63. desdeo/problem/pyomo_evaluator.py +468 -0
  64. desdeo/problem/schema.py +1808 -0
  65. desdeo/problem/simulator_evaluator.py +298 -0
  66. desdeo/problem/sympy_evaluator.py +244 -0
  67. desdeo/problem/testproblems/__init__.py +73 -0
  68. desdeo/problem/testproblems/binh_and_korn_problem.py +88 -0
  69. desdeo/problem/testproblems/dtlz2_problem.py +102 -0
  70. desdeo/problem/testproblems/forest_problem.py +275 -0
  71. desdeo/problem/testproblems/knapsack_problem.py +163 -0
  72. desdeo/problem/testproblems/mcwb_problem.py +831 -0
  73. desdeo/problem/testproblems/mixed_variable_dimenrions_problem.py +83 -0
  74. desdeo/problem/testproblems/momip_problem.py +172 -0
  75. desdeo/problem/testproblems/nimbus_problem.py +143 -0
  76. desdeo/problem/testproblems/pareto_navigator_problem.py +89 -0
  77. desdeo/problem/testproblems/re_problem.py +492 -0
  78. desdeo/problem/testproblems/river_pollution_problem.py +434 -0
  79. desdeo/problem/testproblems/rocket_injector_design_problem.py +140 -0
  80. desdeo/problem/testproblems/simple_problem.py +351 -0
  81. desdeo/problem/testproblems/simulator_problem.py +92 -0
  82. desdeo/problem/testproblems/spanish_sustainability_problem.py +945 -0
  83. desdeo/problem/testproblems/zdt_problem.py +271 -0
  84. desdeo/problem/utils.py +245 -0
  85. desdeo/tools/GenerateReferencePoints.py +181 -0
  86. desdeo/tools/__init__.py +102 -0
  87. desdeo/tools/generics.py +145 -0
  88. desdeo/tools/gurobipy_solver_interfaces.py +258 -0
  89. desdeo/tools/indicators_binary.py +11 -0
  90. desdeo/tools/indicators_unary.py +375 -0
  91. desdeo/tools/interaction_schema.py +38 -0
  92. desdeo/tools/intersection.py +54 -0
  93. desdeo/tools/iterative_pareto_representer.py +99 -0
  94. desdeo/tools/message.py +234 -0
  95. desdeo/tools/ng_solver_interfaces.py +199 -0
  96. desdeo/tools/non_dominated_sorting.py +133 -0
  97. desdeo/tools/patterns.py +281 -0
  98. desdeo/tools/proximal_solver.py +99 -0
  99. desdeo/tools/pyomo_solver_interfaces.py +464 -0
  100. desdeo/tools/reference_vectors.py +462 -0
  101. desdeo/tools/scalarization.py +3138 -0
  102. desdeo/tools/scipy_solver_interfaces.py +454 -0
  103. desdeo/tools/score_bands.py +464 -0
  104. desdeo/tools/utils.py +320 -0
  105. desdeo/utopia_stuff/__init__.py +0 -0
  106. desdeo/utopia_stuff/data/1.json +15 -0
  107. desdeo/utopia_stuff/data/2.json +13 -0
  108. desdeo/utopia_stuff/data/3.json +15 -0
  109. desdeo/utopia_stuff/data/4.json +17 -0
  110. desdeo/utopia_stuff/data/5.json +15 -0
  111. desdeo/utopia_stuff/from_json.py +40 -0
  112. desdeo/utopia_stuff/reinit_user.py +38 -0
  113. desdeo/utopia_stuff/utopia_db_init.py +212 -0
  114. desdeo/utopia_stuff/utopia_problem.py +403 -0
  115. desdeo/utopia_stuff/utopia_problem_old.py +415 -0
  116. desdeo/utopia_stuff/utopia_reference_solutions.py +79 -0
  117. desdeo-2.0.0.dist-info/LICENSE +21 -0
  118. desdeo-2.0.0.dist-info/METADATA +168 -0
  119. desdeo-2.0.0.dist-info/RECORD +120 -0
  120. {desdeo-1.1.3.dist-info → desdeo-2.0.0.dist-info}/WHEEL +1 -1
  121. desdeo-1.1.3.dist-info/METADATA +0 -18
  122. desdeo-1.1.3.dist-info/RECORD +0 -4
@@ -0,0 +1,780 @@
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
+ from random import shuffle
11
+
12
+ import numpy as np
13
+ import polars as pl
14
+
15
+ from desdeo.problem import Problem, VariableDomainTypeEnum
16
+ from desdeo.tools.message import (
17
+ CrossoverMessageTopics,
18
+ FloatMessage,
19
+ Message,
20
+ PolarsDataFrameMessage,
21
+ )
22
+ from desdeo.tools.patterns import Subscriber
23
+
24
+
25
+ class BaseCrossover(Subscriber):
26
+ """A base class for crossover operators."""
27
+
28
+ def __init__(self, problem: Problem, **kwargs):
29
+ """Initialize a crossover operator."""
30
+ super().__init__(**kwargs)
31
+ self.problem = problem
32
+ self.variable_symbols = [var.symbol for var in problem.get_flattened_variables()]
33
+ self.lower_bounds = [var.lowerbound for var in problem.get_flattened_variables()]
34
+ self.upper_bounds = [var.upperbound for var in problem.get_flattened_variables()]
35
+
36
+ self.variable_types = [var.variable_type for var in problem.get_flattened_variables()]
37
+ self.variable_combination: VariableDomainTypeEnum = problem.variable_domain
38
+
39
+ @abstractmethod
40
+ def do(self, *, population: pl.DataFrame, to_mate: list[int] | None = None) -> pl.DataFrame:
41
+ """Perform the crossover operation.
42
+
43
+ Args:
44
+ population (pl.DataFrame): the population to perform the crossover with. The DataFrame
45
+ contains the decision vectors, the target vectors, and the constraint vectors.
46
+ to_mate (list[int] | None): the indices of the population members that should
47
+ participate in the crossover. If `None`, the whole population is subject
48
+ to the crossover.
49
+
50
+ Returns:
51
+ pl.DataFrame: the offspring resulting from the crossover.
52
+ """
53
+
54
+
55
+ class SimulatedBinaryCrossover(BaseCrossover):
56
+ """A class for creating a simulated binary crossover operator.
57
+
58
+ Reference:
59
+ Kalyanmoy Deb and Ram Bhushan Agrawal. 1995. Simulated binary crossover for continuous search space.
60
+ Complex Systems 9, 2 (1995), 115-148.
61
+ """
62
+
63
+ @property
64
+ def provided_topics(self) -> dict[str, Sequence[CrossoverMessageTopics]]:
65
+ """The message topics provided by the crossover operator."""
66
+ return {
67
+ 0: [],
68
+ 1: [CrossoverMessageTopics.XOVER_PROBABILITY, CrossoverMessageTopics.XOVER_DISTRIBUTION],
69
+ 2: [
70
+ CrossoverMessageTopics.XOVER_PROBABILITY,
71
+ CrossoverMessageTopics.XOVER_DISTRIBUTION,
72
+ CrossoverMessageTopics.PARENTS,
73
+ CrossoverMessageTopics.OFFSPRINGS,
74
+ ],
75
+ }
76
+
77
+ @property
78
+ def interested_topics(self):
79
+ """The message topics the crossover operator is interested in."""
80
+ return []
81
+
82
+ def __init__(
83
+ self, *, problem: Problem, seed: int, xover_probability: float = 1.0, xover_distribution: float = 30, **kwargs
84
+ ):
85
+ """Initialize a simulated binary crossover operator.
86
+
87
+ Args:
88
+ problem (Problem): the problem object.
89
+ seed (int): the seed for the random number generator.
90
+ xover_probability (float, optional): the crossover probability
91
+ parameter. Ranges between 0 and 1.0. Defaults to 1.0.
92
+ xover_distribution (float, optional): the crossover distribution
93
+ parameter. Must be positive. Defaults to 30.
94
+ kwargs: Additional keyword arguments. These are passed to the Subscriber class. At the very least, the
95
+ publisher must be passed. See the Subscriber class for more information.
96
+ """
97
+ # Subscribes to no topics, so no need to stroe/pass the topics to the super class.
98
+ super().__init__(problem, **kwargs)
99
+ self.problem = problem
100
+
101
+ if not 0 <= xover_probability <= 1:
102
+ raise ValueError("Crossover probability must be between 0 and 1.")
103
+ if xover_distribution <= 0:
104
+ raise ValueError("Crossover distribution must be positive.")
105
+ self.xover_probability = xover_probability
106
+ self.xover_distribution = xover_distribution
107
+ self.parent_population: pl.DataFrame
108
+ self.offspring_population: pl.DataFrame
109
+ self.rng = np.random.default_rng(seed)
110
+ self.seed = seed
111
+
112
+ def do(
113
+ self,
114
+ *,
115
+ population: pl.DataFrame,
116
+ to_mate: list[int] | None = None,
117
+ ) -> pl.DataFrame:
118
+ """Perform the simulated binary crossover operation.
119
+
120
+ Args:
121
+ population (pl.DataFrame): the population to perform the crossover with. The DataFrame
122
+ contains the decision vectors, the target vectors, and the constraint vectors.
123
+ to_mate (list[int] | None): the indices of the population members that should
124
+ participate in the crossover. If `None`, the whole population is subject
125
+ to the crossover.
126
+
127
+ Returns:
128
+ pl.DataFrame: the offspring resulting from the crossover.
129
+ """
130
+ self.parent_population = population
131
+ pop_size = self.parent_population.shape[0]
132
+ num_var = len(self.variable_symbols)
133
+
134
+ parent_decvars = self.parent_population[self.variable_symbols].to_numpy()
135
+
136
+ if to_mate is None:
137
+ shuffled_ids = list(range(pop_size))
138
+ shuffle(shuffled_ids)
139
+ else:
140
+ shuffled_ids = to_mate
141
+ mating_pop = parent_decvars[shuffled_ids]
142
+ mate_size = len(shuffled_ids)
143
+
144
+ if len(shuffled_ids) % 2 == 1:
145
+ mating_pop = np.vstack((mating_pop, mating_pop[0]))
146
+ mate_size += 1
147
+
148
+ offspring = np.zeros_like(mating_pop)
149
+
150
+ HALF = 0.5 # NOQA: N806
151
+ # TODO(@light-weaver): Extract into a numba jitted function.
152
+ for i in range(0, mate_size, 2):
153
+ beta = np.zeros(num_var)
154
+ miu = self.rng.random(num_var)
155
+ beta[miu <= HALF] = (2 * miu[miu <= HALF]) ** (1 / (self.xover_distribution + 1))
156
+ beta[miu > HALF] = (2 - 2 * miu[miu > HALF]) ** (-1 / (self.xover_distribution + 1))
157
+ beta = beta * ((-1) ** self.rng.integers(low=0, high=2, size=num_var))
158
+ beta[self.rng.random(num_var) > self.xover_probability] = 1
159
+ avg = (mating_pop[i] + mating_pop[i + 1]) / 2
160
+ diff = (mating_pop[i] - mating_pop[i + 1]) / 2
161
+ offspring[i] = avg + beta * diff
162
+ offspring[i + 1] = avg - beta * diff
163
+
164
+ self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols)
165
+ self.notify()
166
+
167
+ return self.offspring_population
168
+
169
+ def update(self, *_, **__):
170
+ """Do nothing. This is just the basic SBX operator."""
171
+
172
+ def state(self) -> Sequence[Message]:
173
+ """Return the state of the crossover operator."""
174
+ if self.parent_population is None or self.offspring_population is None:
175
+ return []
176
+ if self.verbosity == 0:
177
+ return []
178
+ if self.verbosity == 1:
179
+ return [
180
+ FloatMessage(
181
+ topic=CrossoverMessageTopics.XOVER_PROBABILITY,
182
+ source="SimulatedBinaryCrossover",
183
+ value=self.xover_probability,
184
+ ),
185
+ FloatMessage(
186
+ topic=CrossoverMessageTopics.XOVER_DISTRIBUTION,
187
+ source="SimulatedBinaryCrossover",
188
+ value=self.xover_distribution,
189
+ ),
190
+ ]
191
+ # verbosity == 2 or higher
192
+ return [
193
+ FloatMessage(
194
+ topic=CrossoverMessageTopics.XOVER_PROBABILITY,
195
+ source="SimulatedBinaryCrossover",
196
+ value=self.xover_probability,
197
+ ),
198
+ FloatMessage(
199
+ topic=CrossoverMessageTopics.XOVER_DISTRIBUTION,
200
+ source="SimulatedBinaryCrossover",
201
+ value=self.xover_distribution,
202
+ ),
203
+ PolarsDataFrameMessage(
204
+ topic=CrossoverMessageTopics.PARENTS,
205
+ source="SimulatedBinaryCrossover",
206
+ value=self.parent_population,
207
+ ),
208
+ PolarsDataFrameMessage(
209
+ topic=CrossoverMessageTopics.OFFSPRINGS,
210
+ source="SimulatedBinaryCrossover",
211
+ value=self.offspring_population,
212
+ ),
213
+ ]
214
+
215
+
216
+ class SinglePointBinaryCrossover(BaseCrossover):
217
+ """A class that defines the single point binary crossover operation."""
218
+
219
+ def __init__(self, *, problem: Problem, seed: int, **kwargs):
220
+ """Initialize the single point binary crossover operator.
221
+
222
+ Args:
223
+ problem (Problem): the problem object.
224
+ seed (int): the seed used in the random number generator for choosing the crossover point.
225
+ kwargs: Additional keyword arguments. These are passed to the Subscriber class. At the very least, the
226
+ publisher must be passed. See the Subscriber class for more information.
227
+ """
228
+ super().__init__(problem, **kwargs)
229
+ self.seed = seed
230
+
231
+ self.parent_population: pl.DataFrame
232
+ self.offspring_population: pl.DataFrame
233
+ self.rng = np.random.default_rng(seed)
234
+ self.seed = seed
235
+
236
+ @property
237
+ def provided_topics(self) -> dict[str, Sequence[CrossoverMessageTopics]]:
238
+ """The message topics provided by the single point binary crossover operator."""
239
+ return {
240
+ 0: [],
241
+ 1: [],
242
+ 2: [
243
+ CrossoverMessageTopics.PARENTS,
244
+ CrossoverMessageTopics.OFFSPRINGS,
245
+ ],
246
+ }
247
+
248
+ @property
249
+ def interested_topics(self):
250
+ """The message topics the single point binary crossover operator is interested in."""
251
+ return []
252
+
253
+ def do(
254
+ self,
255
+ *,
256
+ population: pl.DataFrame,
257
+ to_mate: list[int] | None = None,
258
+ ) -> pl.DataFrame:
259
+ """Perform single point binary crossover.
260
+
261
+ Args:
262
+ population (pl.DataFrame): the population to perform the crossover with.
263
+ to_mate (list[int] | None, optional): indices. Defaults to None.
264
+
265
+ Returns:
266
+ pl.DataFrame: the offspring from the crossover.
267
+ """
268
+ self.parent_population = population
269
+ pop_size = self.parent_population.shape[0]
270
+ num_var = len(self.variable_symbols)
271
+
272
+ parent_decision_vars = self.parent_population[self.variable_symbols].to_numpy().astype(np.bool)
273
+
274
+ if to_mate is None:
275
+ shuffled_ids = list(range(pop_size))
276
+ shuffle(shuffled_ids)
277
+ else:
278
+ shuffled_ids = copy.copy(to_mate)
279
+
280
+ mating_pop = parent_decision_vars[shuffled_ids]
281
+ mating_pop_size = len(shuffled_ids)
282
+ original_mating_pop_size = mating_pop_size
283
+
284
+ if mating_pop_size % 2 != 0:
285
+ # if the number of member to mate is of uneven size, copy the first member to the tail
286
+ mating_pop = np.vstack((mating_pop, mating_pop[0]))
287
+ mating_pop_size += 1
288
+ shuffled_ids.append(shuffled_ids[0])
289
+
290
+ # split the population into parents, one with members with even numbered indices, the
291
+ # other with uneven numbered indices
292
+ parents1 = mating_pop[[shuffled_ids[i] for i in range(0, mating_pop_size, 2)]]
293
+ parents2 = mating_pop[[shuffled_ids[i] for i in range(1, mating_pop_size, 2)]]
294
+
295
+ cross_over_points = self.rng.integers(1, num_var - 1, mating_pop_size // 2)
296
+
297
+ # create a mask where, on each row, the element is 1 before the crossover point,
298
+ # and zero after it
299
+ cross_over_mask = np.zeros_like(parents1, dtype=np.bool)
300
+ cross_over_mask[np.arange(cross_over_mask.shape[1]) < cross_over_points[:, None]] = 1
301
+
302
+ # pick genes from the first parents before the crossover point
303
+ # pick genes from the second parents after, and including, the crossover point
304
+ offspring1_first = cross_over_mask & parents1
305
+ offspring1_second = (~cross_over_mask) & parents2
306
+
307
+ # combine into a first half of the whole offspring population
308
+ offspring1 = offspring1_first | offspring1_second
309
+
310
+ # pick genes from the first parents after, and including, the crossover point
311
+ # pick genes from the second parents before the crossover point
312
+ offspring2_first = (~cross_over_mask) & parents1
313
+ offspring2_second = cross_over_mask & parents2
314
+
315
+ # combine into the second half of the whole offspring population
316
+ offspring2 = offspring2_first | offspring2_second
317
+
318
+ # combine the two offspring populations into one, drop the last member if the number of
319
+ # indices (to_mate) is uneven
320
+ self.offspring_population = pl.from_numpy(
321
+ np.vstack((offspring1, offspring2))[
322
+ : (original_mating_pop_size if original_mating_pop_size % 2 == 0 else -1)
323
+ ],
324
+ schema=self.variable_symbols,
325
+ ).select(pl.all().cast(pl.Float64))
326
+ self.notify()
327
+
328
+ return self.offspring_population
329
+
330
+ def update(self, *_, **__):
331
+ """Do nothing. This is just the basic single point binary crossover operator."""
332
+
333
+ def state(self) -> Sequence[Message]:
334
+ """Return the state of the single ponit binary crossover operator."""
335
+ if self.parent_population is None or self.offspring_population is None:
336
+ return []
337
+ if self.verbosity == 0:
338
+ return []
339
+ if self.verbosity == 1:
340
+ return []
341
+ # verbosity == 2 or higher
342
+ return [
343
+ PolarsDataFrameMessage(
344
+ topic=CrossoverMessageTopics.PARENTS,
345
+ source="SimulatedBinaryCrossover",
346
+ value=self.parent_population,
347
+ ),
348
+ PolarsDataFrameMessage(
349
+ topic=CrossoverMessageTopics.OFFSPRINGS,
350
+ source="SimulatedBinaryCrossover",
351
+ value=self.offspring_population,
352
+ ),
353
+ ]
354
+
355
+
356
+ class UniformIntegerCrossover(BaseCrossover):
357
+ """A class that defines the uniform integer crossover operation."""
358
+
359
+ def __init__(self, *, problem: Problem, seed: int, **kwargs):
360
+ """Initialize the uniform integer crossover operator.
361
+
362
+ Args:
363
+ problem (Problem): the problem object.
364
+ seed (int): the seed used in the random number generator for choosing the crossover point.
365
+ kwargs: Additional keyword arguments. These are passed to the Subscriber class. At the very least, the
366
+ publisher must be passed. See the Subscriber class for more information.
367
+ """
368
+ super().__init__(problem, **kwargs)
369
+ self.seed = seed
370
+
371
+ self.parent_population: pl.DataFrame
372
+ self.offspring_population: pl.DataFrame
373
+ self.rng = np.random.default_rng(seed)
374
+ self.seed = seed
375
+
376
+ @property
377
+ def provided_topics(self) -> dict[str, Sequence[CrossoverMessageTopics]]:
378
+ """The message topics provided by the single point binary crossover operator."""
379
+ return {
380
+ 0: [],
381
+ 1: [],
382
+ 2: [
383
+ CrossoverMessageTopics.PARENTS,
384
+ CrossoverMessageTopics.OFFSPRINGS,
385
+ ],
386
+ }
387
+
388
+ @property
389
+ def interested_topics(self):
390
+ """The message topics the single point binary crossover operator is interested in."""
391
+ return []
392
+
393
+ def do(
394
+ self,
395
+ *,
396
+ population: pl.DataFrame,
397
+ to_mate: list[int] | None = None,
398
+ ) -> pl.DataFrame:
399
+ """Perform single point binary crossover.
400
+
401
+ Args:
402
+ population (pl.DataFrame): the population to perform the crossover with.
403
+ to_mate (list[int] | None, optional): indices. Defaults to None.
404
+
405
+ Returns:
406
+ pl.DataFrame: the offspring from the crossover.
407
+ """
408
+ self.parent_population = population
409
+ pop_size = self.parent_population.shape[0]
410
+ num_var = len(self.variable_symbols)
411
+
412
+ parent_decision_vars = self.parent_population[self.variable_symbols].to_numpy().astype(int)
413
+
414
+ if to_mate is None:
415
+ shuffled_ids = list(range(pop_size))
416
+ shuffle(shuffled_ids)
417
+ else:
418
+ shuffled_ids = copy.copy(to_mate)
419
+
420
+ mating_pop = parent_decision_vars[shuffled_ids]
421
+ mating_pop_size = len(shuffled_ids)
422
+ original_mating_pop_size = mating_pop_size
423
+
424
+ if mating_pop_size % 2 != 0:
425
+ # if the number of member to mate is of uneven size, copy the first member to the tail
426
+ mating_pop = np.vstack((mating_pop, mating_pop[0]))
427
+ mating_pop_size += 1
428
+ shuffled_ids.append(shuffled_ids[0])
429
+
430
+ # split the population into parents, one with members with even numbered indices, the
431
+ # other with uneven numbered indices
432
+ parents1 = mating_pop[[shuffled_ids[i] for i in range(0, mating_pop_size, 2)]]
433
+ parents2 = mating_pop[[shuffled_ids[i] for i in range(1, mating_pop_size, 2)]]
434
+
435
+ mask = self.rng.choice([True, False], size=num_var)
436
+
437
+ offspring1 = np.where(mask, parents1, parents2) # True, pick from parent1, False, pick from parent2
438
+ offspring2 = np.where(mask, parents2, parents1) # True, pick from parent2, False, pick from parent1
439
+
440
+ # combine the two offspring populations into one, drop the last member if the number of
441
+ # indices (to_mate) is uneven
442
+ self.offspring_population = pl.from_numpy(
443
+ np.vstack((offspring1, offspring2))[
444
+ : (original_mating_pop_size if original_mating_pop_size % 2 == 0 else -1)
445
+ ],
446
+ schema=self.variable_symbols,
447
+ ).select(pl.all().cast(pl.Float64))
448
+
449
+ self.notify()
450
+
451
+ return self.offspring_population
452
+
453
+ def update(self, *_, **__):
454
+ """Do nothing. This is just the basic single point binary crossover operator."""
455
+
456
+ def state(self) -> Sequence[Message]:
457
+ """Return the state of the single ponit binary crossover operator."""
458
+ if self.parent_population is None or self.offspring_population is None:
459
+ return []
460
+ if self.verbosity == 0:
461
+ return []
462
+ if self.verbosity == 1:
463
+ return []
464
+ # verbosity == 2 or higher
465
+ return [
466
+ PolarsDataFrameMessage(
467
+ topic=CrossoverMessageTopics.PARENTS,
468
+ source="SimulatedBinaryCrossover",
469
+ value=self.parent_population,
470
+ ),
471
+ PolarsDataFrameMessage(
472
+ topic=CrossoverMessageTopics.OFFSPRINGS,
473
+ source="SimulatedBinaryCrossover",
474
+ value=self.offspring_population,
475
+ ),
476
+ ]
477
+
478
+
479
+ class UniformMixedIntegerCrossover(BaseCrossover):
480
+ """A class that defines the uniform mixed-integer crossover operation.
481
+
482
+ TODO: This is virtually identical to `UniformIntegerCrossover`. The only
483
+ difference is that the `parent_decision_vars` in `do` are not casted to
484
+ `int`. This is not an ideal way to implement crossover for mixed-integer
485
+ stuff...
486
+ """
487
+
488
+ def __init__(self, *, problem: Problem, seed: int, **kwargs):
489
+ """Initialize the uniform integer crossover operator.
490
+
491
+ Args:
492
+ problem (Problem): the problem object.
493
+ seed (int): the seed used in the random number generator for choosing the crossover point.
494
+ kwargs: Additional keyword arguments. These are passed to the Subscriber class. At the very least, the
495
+ publisher must be passed. See the Subscriber class for more information.
496
+ """
497
+ super().__init__(problem, **kwargs)
498
+ self.seed = seed
499
+
500
+ self.parent_population: pl.DataFrame
501
+ self.offspring_population: pl.DataFrame
502
+ self.rng = np.random.default_rng(seed)
503
+ self.seed = seed
504
+
505
+ @property
506
+ def provided_topics(self) -> dict[str, Sequence[CrossoverMessageTopics]]:
507
+ """The message topics provided by the single point binary crossover operator."""
508
+ return {
509
+ 0: [],
510
+ 1: [],
511
+ 2: [
512
+ CrossoverMessageTopics.PARENTS,
513
+ CrossoverMessageTopics.OFFSPRINGS,
514
+ ],
515
+ }
516
+
517
+ @property
518
+ def interested_topics(self):
519
+ """The message topics the single point binary crossover operator is interested in."""
520
+ return []
521
+
522
+ def do(
523
+ self,
524
+ *,
525
+ population: pl.DataFrame,
526
+ to_mate: list[int] | None = None,
527
+ ) -> pl.DataFrame:
528
+ """Perform single point binary crossover.
529
+
530
+ Args:
531
+ population (pl.DataFrame): the population to perform the crossover with.
532
+ to_mate (list[int] | None, optional): indices. Defaults to None.
533
+
534
+ Returns:
535
+ pl.DataFrame: the offspring from the crossover.
536
+ """
537
+ self.parent_population = population
538
+ pop_size = self.parent_population.shape[0]
539
+ num_var = len(self.variable_symbols)
540
+
541
+ parent_decision_vars = self.parent_population[self.variable_symbols].to_numpy().astype(float)
542
+
543
+ if to_mate is None:
544
+ shuffled_ids = list(range(pop_size))
545
+ shuffle(shuffled_ids)
546
+ else:
547
+ shuffled_ids = copy.copy(to_mate)
548
+
549
+ mating_pop = parent_decision_vars[shuffled_ids]
550
+ mating_pop_size = len(shuffled_ids)
551
+ original_mating_pop_size = mating_pop_size
552
+
553
+ if mating_pop_size % 2 != 0:
554
+ # if the number of member to mate is of uneven size, copy the first member to the tail
555
+ mating_pop = np.vstack((mating_pop, mating_pop[0]))
556
+ mating_pop_size += 1
557
+ shuffled_ids.append(shuffled_ids[0])
558
+
559
+ # split the population into parents, one with members with even numbered indices, the
560
+ # other with uneven numbered indices
561
+ parents1 = mating_pop[[shuffled_ids[i] for i in range(0, mating_pop_size, 2)]]
562
+ parents2 = mating_pop[[shuffled_ids[i] for i in range(1, mating_pop_size, 2)]]
563
+
564
+ mask = self.rng.choice([True, False], size=num_var)
565
+
566
+ offspring1 = np.where(mask, parents1, parents2) # True, pick from parent1, False, pick from parent2
567
+ offspring2 = np.where(mask, parents2, parents1) # True, pick from parent2, False, pick from parent1
568
+
569
+ # combine the two offspring populations into one, drop the last member if the number of
570
+ # indices (to_mate) is uneven
571
+ self.offspring_population = pl.from_numpy(
572
+ np.vstack((offspring1, offspring2))[
573
+ : (original_mating_pop_size if original_mating_pop_size % 2 == 0 else -1)
574
+ ],
575
+ schema=self.variable_symbols,
576
+ ).select(pl.all().cast(pl.Float64))
577
+
578
+ self.notify()
579
+
580
+ return self.offspring_population
581
+
582
+ def update(self, *_, **__):
583
+ """Do nothing. This is just the basic single point binary crossover operator."""
584
+
585
+ def state(self) -> Sequence[Message]:
586
+ """Return the state of the single point binary crossover operator."""
587
+ if self.parent_population is None or self.offspring_population is None:
588
+ return []
589
+ if self.verbosity == 0:
590
+ return []
591
+ if self.verbosity == 1:
592
+ return []
593
+ # verbosity == 2 or higher
594
+ return [
595
+ PolarsDataFrameMessage(
596
+ topic=CrossoverMessageTopics.PARENTS,
597
+ source="SimulatedBinaryCrossover",
598
+ value=self.parent_population,
599
+ ),
600
+ PolarsDataFrameMessage(
601
+ topic=CrossoverMessageTopics.OFFSPRINGS,
602
+ source="SimulatedBinaryCrossover",
603
+ value=self.offspring_population,
604
+ ),
605
+ ]
606
+
607
+
608
+ class BlendAlphaCrossover(BaseCrossover):
609
+ """Blend-alpha (BLX-alpha) crossover for continuous problems."""
610
+
611
+ @property
612
+ def provided_topics(self) -> dict[int, Sequence[CrossoverMessageTopics]]:
613
+ """The message topics provided by the blend alpha crossover operator."""
614
+ return {
615
+ 0: [],
616
+ 1: [
617
+ CrossoverMessageTopics.XOVER_PROBABILITY,
618
+ CrossoverMessageTopics.ALPHA,
619
+ ],
620
+ 2: [
621
+ CrossoverMessageTopics.XOVER_PROBABILITY,
622
+ CrossoverMessageTopics.ALPHA,
623
+ CrossoverMessageTopics.PARENTS,
624
+ CrossoverMessageTopics.OFFSPRINGS,
625
+ ],
626
+ }
627
+
628
+ @property
629
+ def interested_topics(self):
630
+ """The message topics provided by the blend alpha crossover operator."""
631
+ return []
632
+
633
+ def __init__(
634
+ self,
635
+ *,
636
+ problem: Problem,
637
+ seed: int = 0,
638
+ alpha: float = 0.5,
639
+ xover_probability: float = 1.0,
640
+ **kwargs,
641
+ ):
642
+ """Initialize the blend alpha crossover operator.
643
+
644
+ Args:
645
+ problem (Problem): the problem object.
646
+ seed (int): the seed used in the random number generator for choosing the crossover point.
647
+ alpha (float, optional): non-negative blending factor 'alpha' that controls the extent to which
648
+ offspring may be sampled outside the interval defined by each pair of parent
649
+ genes. alpha = 0 restricts children strictly within the
650
+ parents range, larger alpha allows some outliers. Defaults to 0.5.
651
+ xover_probability (float, optional): the crossover probability parameter.
652
+ Ranges between 0 and 1.0. Defaults to 1.0.
653
+ kwargs: Additional keyword arguments. These are passed to the Subscriber class. At the very least, the
654
+ publisher must be passed. See the Subscriber class for more information.
655
+ """
656
+ super().__init__(problem=problem, **kwargs)
657
+
658
+ if problem.variable_domain is not VariableDomainTypeEnum.continuous:
659
+ raise ValueError("BlendAlphaCrossover only works on continuous problems.")
660
+
661
+ if not 0 <= xover_probability <= 1:
662
+ raise ValueError("Crossover probability must be in [0,1].")
663
+ if alpha < 0:
664
+ raise ValueError("Alpha must be non-negative.")
665
+
666
+ self.alpha = alpha
667
+ self.xover_probability = xover_probability
668
+ self.seed = seed
669
+
670
+ self.parent_population: pl.DataFrame | None = None
671
+ self.offspring_population: pl.DataFrame | None = None
672
+
673
+ def do(
674
+ self,
675
+ *,
676
+ population: pl.DataFrame,
677
+ to_mate: list[int] | None = None,
678
+ ) -> pl.DataFrame:
679
+ """Perform BLX-alpha crossover.
680
+
681
+ Args:
682
+ population (pl.DataFrame): the population to perform the crossover with. The DataFrame
683
+ contains the decision vectors, the target vectors, and the constraint vectors.
684
+ to_mate (list[int] | None): the indices of the population members that should
685
+ participate in the crossover. If `None`, the whole population is subject
686
+ to the crossover.
687
+
688
+ Returns:
689
+ pl.DataFrame: the offspring resulting from the crossover.
690
+ """
691
+ self.parent_population = population
692
+ pop_size = population.shape[0]
693
+ num_var = len(self.variable_symbols)
694
+
695
+ parent_decision_vars = population[self.variable_symbols].to_numpy()
696
+ if to_mate is None:
697
+ shuffled_ids = list(range(pop_size))
698
+ shuffle(shuffled_ids)
699
+ else:
700
+ shuffled_ids = copy.copy(to_mate)
701
+
702
+ mating_pop_size = len(shuffled_ids)
703
+ original_pop_size = mating_pop_size
704
+ if mating_pop_size % 2 == 1:
705
+ shuffled_ids.append(shuffled_ids[0])
706
+ mating_pop_size += 1
707
+
708
+ mating_pop = parent_decision_vars[shuffled_ids]
709
+
710
+ parents1 = mating_pop[0::2, :]
711
+ parents2 = mating_pop[1::2, :]
712
+
713
+ c_min = np.minimum(parents1, parents2)
714
+ c_max = np.maximum(parents1, parents2)
715
+ span = c_max - c_min
716
+
717
+ lower = c_min - self.alpha * span
718
+ upper = c_max + self.alpha * span
719
+
720
+ rng = np.random.default_rng(self.seed)
721
+
722
+ unoform_1 = rng.random((mating_pop_size // 2, num_var))
723
+ uniform_2 = rng.random((mating_pop_size // 2, num_var))
724
+
725
+ offspring1 = lower + unoform_1 * (upper - lower)
726
+ offspring2 = lower + uniform_2 * (upper - lower)
727
+
728
+ mask = rng.random(mating_pop_size // 2) > self.xover_probability
729
+ offspring1[mask, :] = parents1[mask, :]
730
+ offspring2[mask, :] = parents2[mask, :]
731
+
732
+ offspring = np.vstack((offspring1, offspring2))
733
+ if original_pop_size % 2 == 1:
734
+ offspring = offspring[:-1, :]
735
+
736
+ self.offspring_population = pl.from_numpy(offspring, schema=self.variable_symbols).select(
737
+ pl.all().cast(pl.Float64)
738
+ )
739
+ self.notify()
740
+ return self.offspring_population
741
+
742
+ def update(self, *_, **__):
743
+ """Do nothing."""
744
+
745
+ def state(self) -> Sequence[Message]:
746
+ """Return the state of the blend-alpha crossover operator."""
747
+ if self.parent_population is None:
748
+ return []
749
+ msgs: list[Message] = []
750
+ if self.verbosity >= 1:
751
+ msgs.append(
752
+ FloatMessage(
753
+ topic=CrossoverMessageTopics.XOVER_PROBABILITY,
754
+ source=self.__class__.__name__,
755
+ value=self.xover_probability,
756
+ )
757
+ )
758
+ msgs.append(
759
+ FloatMessage(
760
+ topic=CrossoverMessageTopics.ALPHA,
761
+ source=self.__class__.__name__,
762
+ value=self.alpha,
763
+ )
764
+ )
765
+ if self.verbosity >= 2: # noqa: PLR2004
766
+ msgs.extend(
767
+ [
768
+ PolarsDataFrameMessage(
769
+ topic=CrossoverMessageTopics.PARENTS,
770
+ source=self.__class__.__name__,
771
+ value=self.parent_population,
772
+ ),
773
+ PolarsDataFrameMessage(
774
+ topic=CrossoverMessageTopics.OFFSPRINGS,
775
+ source=self.__class__.__name__,
776
+ value=self.offspring_population,
777
+ ),
778
+ ]
779
+ )
780
+ return msgs