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,1224 @@
1
+ """Evolutionary operators for mutation.
2
+
3
+ Various evolutionary operators for mutation in multiobjective optimization are defined here.
4
+ """
5
+
6
+ import copy
7
+ from abc import abstractmethod
8
+ from collections.abc import Sequence
9
+
10
+ import numpy as np
11
+ import polars as pl
12
+
13
+ from desdeo.problem import Problem, VariableDomainTypeEnum, VariableTypeEnum
14
+ from desdeo.tools.message import (
15
+ FloatMessage,
16
+ Message,
17
+ MutationMessageTopics,
18
+ PolarsDataFrameMessage,
19
+ TerminatorMessageTopics,
20
+ )
21
+ from desdeo.tools.patterns import Publisher, Subscriber
22
+
23
+
24
+ class BaseMutation(Subscriber):
25
+ """A base class for mutation operators."""
26
+
27
+ @abstractmethod
28
+ def __init__(self, problem: Problem, verbosity: int, publisher: Publisher):
29
+ """Initialize a mutation operator."""
30
+ super().__init__(verbosity=verbosity, publisher=publisher)
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
+ 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, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
40
+ """Perform the mutation operation.
41
+
42
+ Args:
43
+ offsprings (pl.DataFrame): the offspring population to mutate.
44
+ parents (pl.DataFrame): the parent population from which the offspring
45
+ was generated (via crossover).
46
+
47
+ Returns:
48
+ pl.DataFrame: the offspring resulting from the mutation.
49
+ """
50
+
51
+
52
+ class BoundedPolynomialMutation(BaseMutation):
53
+ """Implements the bounded polynomial mutation operator.
54
+
55
+ Reference:
56
+ Deb, K., & Goyal, M. (1996). A combined genetic adaptive search (GeneAS) for
57
+ engineering design. Computer Science and informatics, 26(4), 30-45, 1996.
58
+ """
59
+
60
+ @property
61
+ def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
62
+ """The message topics provided by the mutation operator."""
63
+ return {
64
+ 0: [],
65
+ 1: [
66
+ MutationMessageTopics.MUTATION_PROBABILITY,
67
+ MutationMessageTopics.MUTATION_DISTRIBUTION,
68
+ ],
69
+ 2: [
70
+ MutationMessageTopics.MUTATION_PROBABILITY,
71
+ MutationMessageTopics.MUTATION_DISTRIBUTION,
72
+ MutationMessageTopics.OFFSPRING_ORIGINAL,
73
+ MutationMessageTopics.OFFSPRINGS,
74
+ ],
75
+ }
76
+
77
+ @property
78
+ def interested_topics(self):
79
+ """The message topics that the mutation operator is interested in."""
80
+ return []
81
+
82
+ def __init__(
83
+ self,
84
+ *,
85
+ problem: Problem,
86
+ seed: int,
87
+ verbosity: int,
88
+ publisher: Publisher,
89
+ mutation_probability: float | None = None,
90
+ distribution_index: float = 20,
91
+ ):
92
+ """Initialize a bounded polynomial mutation operator.
93
+
94
+ Args:
95
+ problem (Problem): The problem object.
96
+ seed (int): The seed for the random number generator.
97
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
98
+ messages are provided at each verbosity level. Recommended value is 1.
99
+ publisher (Publisher): The publisher to which the operator will send messages.
100
+ mutation_probability (float | None, optional): The probability of mutation. Defaults to None.
101
+ distribution_index (float, optional): The distribution index for polynomial mutation. Defaults to 20.
102
+ """
103
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
104
+ if self.variable_combination != VariableDomainTypeEnum.continuous:
105
+ raise ValueError("This mutation operator only works with continuous variables.")
106
+ if mutation_probability is None:
107
+ self.mutation_probability = 1 / len(self.lower_bounds)
108
+ else:
109
+ self.mutation_probability = mutation_probability
110
+ self.distribution_index = distribution_index
111
+ self.rng = np.random.default_rng(seed)
112
+ self.seed = seed
113
+ self.offspring_original: pl.DataFrame
114
+ self.parents: pl.DataFrame
115
+ self.offspring: pl.DataFrame
116
+
117
+ def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
118
+ """Perform the mutation operation.
119
+
120
+ Args:
121
+ offsprings (pl.DataFrame): the offspring population to mutate.
122
+ parents (pl.DataFrame): the parent population from which the offspring
123
+ was generated (via crossover).
124
+
125
+ Returns:
126
+ pl.DataFrame: the offspring resulting from the mutation.
127
+ """
128
+ # TODO(@light-weaver): Extract to a numba jitted function
129
+ self.offspring_original = offsprings
130
+ self.parents = parents # Not used, but kept for consistency
131
+ offspring = offsprings.to_numpy(writable=True)
132
+ min_val = np.ones_like(offspring) * self.lower_bounds
133
+ max_val = np.ones_like(offspring) * self.upper_bounds
134
+ k = self.rng.random(size=offspring.shape)
135
+ miu = self.rng.random(size=offspring.shape)
136
+ temp = np.logical_and((k <= self.mutation_probability), (miu < 0.5))
137
+ offspring_scaled = (offspring - min_val) / (max_val - min_val)
138
+ offspring[temp] = offspring[temp] + (
139
+ (max_val[temp] - min_val[temp])
140
+ * (
141
+ (2 * miu[temp] + (1 - 2 * miu[temp]) * (1 - offspring_scaled[temp]) ** (self.distribution_index + 1))
142
+ ** (1 / (self.distribution_index + 1))
143
+ - 1
144
+ )
145
+ )
146
+ temp = np.logical_and((k <= self.mutation_probability), (miu >= 0.5))
147
+ offspring[temp] = offspring[temp] + (
148
+ (max_val[temp] - min_val[temp])
149
+ * (
150
+ 1
151
+ - (
152
+ 2 * (1 - miu[temp])
153
+ + 2 * (miu[temp] - 0.5) * offspring_scaled[temp] ** (self.distribution_index + 1)
154
+ )
155
+ ** (1 / (self.distribution_index + 1))
156
+ )
157
+ )
158
+ offspring[offspring > max_val] = max_val[offspring > max_val]
159
+ offspring[offspring < min_val] = min_val[offspring < min_val]
160
+ self.offspring = pl.from_numpy(offspring, schema=self.variable_symbols)
161
+ self.notify()
162
+ return self.offspring
163
+
164
+ def update(self, *_, **__):
165
+ """Do nothing. This is just the basic polynomial mutation operator."""
166
+
167
+ def state(self) -> Sequence[Message]:
168
+ """Return the state of the mutation operator."""
169
+ if self.offspring_original is None or self.offspring is None:
170
+ return []
171
+ if self.verbosity == 0:
172
+ return []
173
+ if self.verbosity == 1:
174
+ return [
175
+ FloatMessage(
176
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
177
+ source=self.__class__.__name__,
178
+ value=self.mutation_probability,
179
+ ),
180
+ FloatMessage(
181
+ topic=MutationMessageTopics.MUTATION_DISTRIBUTION,
182
+ source=self.__class__.__name__,
183
+ value=self.distribution_index,
184
+ ),
185
+ ]
186
+ # verbosity == 2
187
+ return [
188
+ PolarsDataFrameMessage(
189
+ topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
190
+ source=self.__class__.__name__,
191
+ value=self.offspring_original,
192
+ ),
193
+ PolarsDataFrameMessage(
194
+ topic=MutationMessageTopics.PARENTS,
195
+ source=self.__class__.__name__,
196
+ value=self.parents,
197
+ ),
198
+ PolarsDataFrameMessage(
199
+ topic=MutationMessageTopics.OFFSPRINGS,
200
+ source=self.__class__.__name__,
201
+ value=self.offspring,
202
+ ),
203
+ FloatMessage(
204
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
205
+ source=self.__class__.__name__,
206
+ value=self.mutation_probability,
207
+ ),
208
+ FloatMessage(
209
+ topic=MutationMessageTopics.MUTATION_DISTRIBUTION,
210
+ source=self.__class__.__name__,
211
+ value=self.distribution_index,
212
+ ),
213
+ ]
214
+
215
+
216
+ class BinaryFlipMutation(BaseMutation):
217
+ """Implements the bit flip mutation operator for binary variables.
218
+
219
+ The binary flip mutation will mutate each binary decision variable,
220
+ by flipping it (0 to 1, 1 to 0) with a provided probability.
221
+ """
222
+
223
+ @property
224
+ def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
225
+ """The message topics provided by the mutation operator."""
226
+ return {
227
+ 0: [],
228
+ 1: [
229
+ MutationMessageTopics.MUTATION_PROBABILITY,
230
+ ],
231
+ 2: [
232
+ MutationMessageTopics.MUTATION_PROBABILITY,
233
+ MutationMessageTopics.OFFSPRING_ORIGINAL,
234
+ MutationMessageTopics.OFFSPRINGS,
235
+ ],
236
+ }
237
+
238
+ @property
239
+ def interested_topics(self):
240
+ """The message topics that the mutation operator is interested in."""
241
+ return []
242
+
243
+ def __init__(
244
+ self,
245
+ *,
246
+ problem: Problem,
247
+ seed: int,
248
+ verbosity: int,
249
+ publisher: Publisher,
250
+ mutation_probability: float | None = None,
251
+ ):
252
+ """Initialize a binary flip mutation operator.
253
+
254
+ Args:
255
+ problem (Problem): The problem object.
256
+ seed (int): The seed for the random number generator.
257
+ mutation_probability (float | None, optional): The probability of mutation. If None,
258
+ the probability will be set to be 1/n, where n is the number of decision variables
259
+ in the problem. Defaults to None.
260
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
261
+ messages are provided at each verbosity level. Recommended value is 1.
262
+ publisher (Publisher): The publisher to which the operator will send messages.
263
+ """
264
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
265
+
266
+ if self.variable_combination != VariableDomainTypeEnum.binary:
267
+ raise ValueError("This mutation operator only works with binary variables.")
268
+ if mutation_probability is None:
269
+ self.mutation_probability = 1 / len(self.variable_symbols)
270
+ else:
271
+ self.mutation_probability = mutation_probability
272
+
273
+ self.rng = np.random.default_rng(seed)
274
+ self.seed = seed
275
+ self.offspring_original: pl.DataFrame
276
+ self.parents: pl.DataFrame
277
+ self.offspring: pl.DataFrame
278
+
279
+ def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
280
+ """Perform the binary flip mutation operation.
281
+
282
+ Args:
283
+ offsprings (pl.DataFrame): the offspring population to mutate.
284
+ parents (pl.DataFrame): the parent population from which the offspring
285
+ was generated (via crossover). Not used in the mutation operator.
286
+
287
+ Returns:
288
+ pl.DataFrame: the offspring resulting from the mutation.
289
+ """
290
+ self.offspring_original = copy.copy(offsprings)
291
+ self.parents = parents # Not used, but kept for consistency
292
+ offspring = offsprings.to_numpy(writable=True).astype(dtype=np.bool)
293
+
294
+ # create a boolean mask based on the mutation probability
295
+ flip_mask = self.rng.random(offspring.shape) < self.mutation_probability
296
+
297
+ # using XOR (^), flip the bits in the offspring when the mask is True
298
+ # otherwise leave the bit's value as it is
299
+ offspring = offspring ^ flip_mask
300
+
301
+ self.offspring = pl.from_numpy(offspring, schema=self.variable_symbols).select(pl.all()).cast(pl.Float64)
302
+ self.notify()
303
+
304
+ return self.offspring
305
+
306
+ def update(self, *_, **__):
307
+ """Do nothing."""
308
+
309
+ def state(self) -> Sequence[Message]:
310
+ """Return the state of the mutation operator."""
311
+ if self.offspring_original is None or self.offspring is None:
312
+ return []
313
+ if self.verbosity == 0:
314
+ return []
315
+ if self.verbosity == 1:
316
+ return [
317
+ FloatMessage(
318
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
319
+ source=self.__class__.__name__,
320
+ value=self.mutation_probability,
321
+ ),
322
+ ]
323
+ # verbosity == 2
324
+ return [
325
+ PolarsDataFrameMessage(
326
+ topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
327
+ source=self.__class__.__name__,
328
+ value=self.offspring_original,
329
+ ),
330
+ PolarsDataFrameMessage(
331
+ topic=MutationMessageTopics.PARENTS,
332
+ source=self.__class__.__name__,
333
+ value=self.parents,
334
+ ),
335
+ PolarsDataFrameMessage(
336
+ topic=MutationMessageTopics.OFFSPRINGS,
337
+ source=self.__class__.__name__,
338
+ value=self.offspring,
339
+ ),
340
+ FloatMessage(
341
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
342
+ source=self.__class__.__name__,
343
+ value=self.mutation_probability,
344
+ ),
345
+ ]
346
+
347
+
348
+ class IntegerRandomMutation(BaseMutation):
349
+ """Implements a random mutation operator for integer variables.
350
+
351
+ The mutation will mutate each binary integer variable,
352
+ by changing its value to a random value bounded by the
353
+ variable's bounds with a provided probability.
354
+ """
355
+
356
+ @property
357
+ def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
358
+ """The message topics provided by the mutation operator."""
359
+ return {
360
+ 0: [],
361
+ 1: [
362
+ MutationMessageTopics.MUTATION_PROBABILITY,
363
+ ],
364
+ 2: [
365
+ MutationMessageTopics.MUTATION_PROBABILITY,
366
+ MutationMessageTopics.OFFSPRING_ORIGINAL,
367
+ MutationMessageTopics.OFFSPRINGS,
368
+ ],
369
+ }
370
+
371
+ @property
372
+ def interested_topics(self):
373
+ """The message topics that the mutation operator is interested in."""
374
+ return []
375
+
376
+ def __init__(
377
+ self,
378
+ *,
379
+ problem: Problem,
380
+ seed: int,
381
+ verbosity: int,
382
+ publisher: Publisher,
383
+ mutation_probability: float | None = None,
384
+ ):
385
+ """Initialize a random integer mutation operator.
386
+
387
+ Args:
388
+ problem (Problem): The problem object.
389
+ seed (int): The seed for the random number generator.
390
+ mutation_probability (float | None, optional): The probability of mutation. If None,
391
+ the probability will be set to be 1/n, where n is the number of decision variables
392
+ in the problem. Defaults to None.
393
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
394
+ messages are provided at each verbosity level. Recommended value is 1.
395
+ publisher (Publisher): The publisher to which the operator will send messages.
396
+ """
397
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
398
+
399
+ if self.variable_combination != VariableDomainTypeEnum.integer:
400
+ raise ValueError("This mutation operator only works with integer variables.")
401
+ if mutation_probability is None:
402
+ self.mutation_probability = 1 / len(self.variable_symbols)
403
+ else:
404
+ self.mutation_probability = mutation_probability
405
+
406
+ self.rng = np.random.default_rng(seed)
407
+ self.seed = seed
408
+ self.offspring_original: pl.DataFrame
409
+ self.parents: pl.DataFrame
410
+ self.offspring: pl.DataFrame
411
+
412
+ def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
413
+ """Perform the random integer mutation operation.
414
+
415
+ Args:
416
+ offsprings (pl.DataFrame): the offspring population to mutate.
417
+ parents (pl.DataFrame): the parent population from which the offspring
418
+ was generated (via crossover). Not used in the mutation operator.
419
+
420
+ Returns:
421
+ pl.DataFrame: the offspring resulting from the mutation.
422
+ """
423
+ self.offspring_original = copy.copy(offsprings)
424
+ self.parents = parents # Not used, but kept for consistency
425
+
426
+ population = offsprings.to_numpy(writable=True).astype(int)
427
+
428
+ # create a boolean mask based on the mutation probability
429
+ mutation_mask = self.rng.random(population.shape) < self.mutation_probability
430
+
431
+ mutated = np.where(
432
+ mutation_mask,
433
+ self.rng.integers(self.lower_bounds, self.upper_bounds, size=population.shape, dtype=int, endpoint=True),
434
+ population,
435
+ )
436
+
437
+ self.offspring = pl.from_numpy(mutated, schema=self.variable_symbols).select(pl.all()).cast(pl.Float64)
438
+ self.notify()
439
+
440
+ return self.offspring
441
+
442
+ def update(self, *_, **__):
443
+ """Do nothing."""
444
+
445
+ def state(self) -> Sequence[Message]:
446
+ """Return the state of the mutation operator."""
447
+ if self.offspring_original is None or self.offspring is None:
448
+ return []
449
+ if self.verbosity == 0:
450
+ return []
451
+ if self.verbosity == 1:
452
+ return [
453
+ FloatMessage(
454
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
455
+ source=self.__class__.__name__,
456
+ value=self.mutation_probability,
457
+ ),
458
+ ]
459
+ # verbosity == 2
460
+ return [
461
+ PolarsDataFrameMessage(
462
+ topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
463
+ source=self.__class__.__name__,
464
+ value=self.offspring_original,
465
+ ),
466
+ PolarsDataFrameMessage(
467
+ topic=MutationMessageTopics.PARENTS,
468
+ source=self.__class__.__name__,
469
+ value=self.parents,
470
+ ),
471
+ PolarsDataFrameMessage(
472
+ topic=MutationMessageTopics.OFFSPRINGS,
473
+ source=self.__class__.__name__,
474
+ value=self.offspring,
475
+ ),
476
+ FloatMessage(
477
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
478
+ source=self.__class__.__name__,
479
+ value=self.mutation_probability,
480
+ ),
481
+ ]
482
+
483
+
484
+ class MixedIntegerRandomMutation(BaseMutation):
485
+ """Implements a random mutation operator for mixed-integer variables.
486
+
487
+ The mutation will mutate each mixed-integer variable,
488
+ by changing its value to a random value bounded by the
489
+ variable's bounds with a provided probability.
490
+ """
491
+
492
+ @property
493
+ def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
494
+ """The message topics provided by the mutation operator."""
495
+ return {
496
+ 0: [],
497
+ 1: [
498
+ MutationMessageTopics.MUTATION_PROBABILITY,
499
+ ],
500
+ 2: [
501
+ MutationMessageTopics.MUTATION_PROBABILITY,
502
+ MutationMessageTopics.OFFSPRING_ORIGINAL,
503
+ MutationMessageTopics.OFFSPRINGS,
504
+ ],
505
+ }
506
+
507
+ @property
508
+ def interested_topics(self):
509
+ """The message topics that the mutation operator is interested in."""
510
+ return []
511
+
512
+ def __init__(
513
+ self,
514
+ *,
515
+ problem: Problem,
516
+ seed: int,
517
+ verbosity: int,
518
+ publisher: Publisher,
519
+ mutation_probability: float | None = None,
520
+ ):
521
+ """Initialize a random mixed_integer mutation operator.
522
+
523
+ Args:
524
+ problem (Problem): The problem object.
525
+ seed (int): The seed for the random number generator.
526
+ mutation_probability (float | None, optional): The probability of mutation. If None,
527
+ the probability will be set to be 1/n, where n is the number of decision variables
528
+ in the problem. Defaults to None.
529
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
530
+ messages are provided at each verbosity level. Recommended value is 1.
531
+ publisher (Publisher): The publisher to which the operator will send messages.
532
+ """
533
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
534
+
535
+ if mutation_probability is None:
536
+ self.mutation_probability = 1 / len(self.variable_symbols)
537
+ else:
538
+ self.mutation_probability = mutation_probability
539
+
540
+ self.rng = np.random.default_rng(seed)
541
+ self.seed = seed
542
+ self.offspring_original: pl.DataFrame
543
+ self.parents: pl.DataFrame
544
+ self.offspring: pl.DataFrame
545
+
546
+ def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
547
+ """Perform the random integer mutation operation.
548
+
549
+ Args:
550
+ offsprings (pl.DataFrame): the offspring population to mutate.
551
+ parents (pl.DataFrame): the parent population from which the offspring
552
+ was generated (via crossover). Not used in the mutation operator.
553
+
554
+ Returns:
555
+ pl.DataFrame: the offspring resulting from the mutation.
556
+ """
557
+ self.offspring_original = copy.copy(offsprings)
558
+ self.parents = parents # Not used, but kept for consistency
559
+
560
+ population = offsprings.to_numpy(writable=True).astype(float)
561
+
562
+ # create a boolean mask based on the mutation probability
563
+ mutation_mask = self.rng.random(population.shape) < self.mutation_probability
564
+
565
+ mutation_pool = np.array(
566
+ [
567
+ self.rng.integers(
568
+ low=var.lowerbound, high=var.upperbound, size=population.shape[0], endpoint=True
569
+ ).astype(dtype=float)
570
+ if var.variable_type in [VariableTypeEnum.binary, VariableTypeEnum.integer]
571
+ else self.rng.uniform(low=var.lowerbound, high=var.upperbound, size=population.shape[0]).astype(
572
+ dtype=float
573
+ )
574
+ for var in self.problem.variables
575
+ ]
576
+ ).T
577
+
578
+ mutated = np.where(
579
+ mutation_mask,
580
+ # self.rng.integers(self.lower_bounds, self.upper_bounds, size=population.shape, dtype=int, endpoint=True),
581
+ mutation_pool,
582
+ population,
583
+ )
584
+
585
+ self.offspring = pl.from_numpy(mutated, schema=self.variable_symbols).select(pl.all()).cast(pl.Float64)
586
+ self.notify()
587
+
588
+ return self.offspring
589
+
590
+ def update(self, *_, **__):
591
+ """Do nothing."""
592
+
593
+ def state(self) -> Sequence[Message]:
594
+ """Return the state of the mutation operator."""
595
+ if self.offspring_original is None or self.offspring is None:
596
+ return []
597
+ if self.verbosity == 0:
598
+ return []
599
+ if self.verbosity == 1:
600
+ return [
601
+ FloatMessage(
602
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
603
+ source=self.__class__.__name__,
604
+ value=self.mutation_probability,
605
+ ),
606
+ ]
607
+ # verbosity == 2
608
+ return [
609
+ PolarsDataFrameMessage(
610
+ topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
611
+ source=self.__class__.__name__,
612
+ value=self.offspring_original,
613
+ ),
614
+ PolarsDataFrameMessage(
615
+ topic=MutationMessageTopics.PARENTS,
616
+ source=self.__class__.__name__,
617
+ value=self.parents,
618
+ ),
619
+ PolarsDataFrameMessage(
620
+ topic=MutationMessageTopics.OFFSPRINGS,
621
+ source=self.__class__.__name__,
622
+ value=self.offspring,
623
+ ),
624
+ FloatMessage(
625
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
626
+ source=self.__class__.__name__,
627
+ value=self.mutation_probability,
628
+ ),
629
+ ]
630
+
631
+
632
+ class MPTMutation(BaseMutation):
633
+ """Makinen, Periaux and Toivanen (MTP) mutation.
634
+
635
+ Applies small mutations to mixed-integer variables using a mutation exponent strategy.
636
+ """
637
+
638
+ @property
639
+ def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
640
+ """The message topics provided by the mutation operator."""
641
+ return {
642
+ 0: [],
643
+ 1: [MutationMessageTopics.MUTATION_PROBABILITY],
644
+ 2: [
645
+ MutationMessageTopics.MUTATION_PROBABILITY,
646
+ MutationMessageTopics.OFFSPRING_ORIGINAL,
647
+ MutationMessageTopics.OFFSPRINGS,
648
+ ],
649
+ }
650
+
651
+ @property
652
+ def interested_topics(self):
653
+ """The message topics that the mutation operator is interested in."""
654
+ return []
655
+
656
+ def __init__(
657
+ self,
658
+ *,
659
+ problem: Problem,
660
+ seed: int,
661
+ verbosity: int,
662
+ publisher: Publisher,
663
+ mutation_probability: float | None = None,
664
+ mutation_exponent: float = 2.0,
665
+ ):
666
+ """Initialize a small mutation operator.
667
+
668
+ Args:
669
+ problem (Problem): Optimization problem.
670
+ seed (int): RNG seed.
671
+ mutation_probability (float | None): Probability of mutation per gene.
672
+ mutation_exponent (float): Controls strength of small mutation (larger means smaller mutations).
673
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
674
+ messages are provided at each verbosity level. Recommended value is 1.
675
+ publisher (Publisher): The publisher to which the operator will send messages.
676
+ publisher must be passed. See the Subscriber class for more information.
677
+ """
678
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
679
+ self.rng = np.random.default_rng(seed)
680
+ self.seed = seed
681
+ self.mutation_exponent = mutation_exponent
682
+ self.mutation_probability = (
683
+ 1 / len(self.variable_symbols) if mutation_probability is None else mutation_probability
684
+ )
685
+
686
+ def _mutate_value(self, x, lower_bound, upper_bound):
687
+ """Apply small mutation to a single float value using mutation exponent."""
688
+ t = (x - lower_bound) / (upper_bound - lower_bound)
689
+ rnd = self.rng.uniform(0, 1)
690
+
691
+ if rnd < t:
692
+ tm = t - t * ((t - rnd) / t) ** self.mutation_exponent
693
+ elif rnd > t:
694
+ tm = t + (1 - t) * ((rnd - t) / (1 - t)) ** self.mutation_exponent
695
+ else:
696
+ tm = t
697
+
698
+ return (1 - tm) * lower_bound + tm * upper_bound
699
+
700
+ def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
701
+ """Perform the MPT mutation operation.
702
+
703
+ Args:
704
+ offsprings (pl.DataFrame): the offspring population to mutate.
705
+ parents (pl.DataFrame): the parent population from which the offspring
706
+ was generated (via crossover). Not used in the mutation operator.
707
+
708
+ Returns:
709
+ pl.DataFrame: the offspring resulting from the mutation.
710
+ """
711
+ self.offspring_original = copy.copy(offsprings)
712
+ self.parents = parents
713
+
714
+ population = offsprings.to_numpy(writable=True).astype(float)
715
+
716
+ for i in range(population.shape[0]):
717
+ for j, var in enumerate(self.problem.variables):
718
+ if self.rng.random() < self.mutation_probability:
719
+ x = population[i, j]
720
+ lower_bound, upper_bound = var.lowerbound, var.upperbound
721
+ if var.variable_type in [VariableTypeEnum.binary, VariableTypeEnum.integer]:
722
+ # Round after float mutation to keep integer domain
723
+ population[i, j] = round(self._mutate_value(x, lower_bound, upper_bound))
724
+ else:
725
+ population[i, j] = self._mutate_value(x, lower_bound, upper_bound)
726
+
727
+ self.offspring = pl.from_numpy(population, schema=self.variable_symbols).select(pl.all()).cast(pl.Float64)
728
+ self.notify()
729
+ return self.offspring
730
+
731
+ def update(self, *_, **__):
732
+ """Do nothing."""
733
+
734
+ def state(self) -> Sequence[Message]:
735
+ """Return the state of the mutation operator."""
736
+ if self.offspring_original is None or self.offspring is None:
737
+ return []
738
+ if self.verbosity == 0:
739
+ return []
740
+ if self.verbosity == 1:
741
+ return [
742
+ FloatMessage(
743
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
744
+ source=self.__class__.__name__,
745
+ value=self.mutation_probability,
746
+ ),
747
+ ]
748
+ return [
749
+ PolarsDataFrameMessage(
750
+ topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
751
+ source=self.__class__.__name__,
752
+ value=self.offspring_original,
753
+ ),
754
+ PolarsDataFrameMessage(
755
+ topic=MutationMessageTopics.PARENTS,
756
+ source=self.__class__.__name__,
757
+ value=self.parents,
758
+ ),
759
+ PolarsDataFrameMessage(
760
+ topic=MutationMessageTopics.OFFSPRINGS,
761
+ source=self.__class__.__name__,
762
+ value=self.offspring,
763
+ ),
764
+ FloatMessage(
765
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
766
+ source=self.__class__.__name__,
767
+ value=self.mutation_probability,
768
+ ),
769
+ ]
770
+
771
+
772
+ #TODO (@light-weaver): Get rid of the max_generations parameter and instead get it from a message
773
+ # Additionally, allow the operator to use max_evaluations as a basis for decay
774
+ # Make sure that the ratio never exceeds 1.0, otherwise there will be issues
775
+ class NonUniformMutation(BaseMutation):
776
+ """Non-uniform mutation operator.
777
+
778
+ The mutation strength decays over generations.
779
+ """
780
+
781
+ @property
782
+ def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
783
+ """The message topics provided by the mutation operator."""
784
+ return {
785
+ 0: [],
786
+ 1: [MutationMessageTopics.MUTATION_PROBABILITY],
787
+ 2: [
788
+ MutationMessageTopics.MUTATION_PROBABILITY,
789
+ MutationMessageTopics.OFFSPRING_ORIGINAL,
790
+ MutationMessageTopics.OFFSPRINGS,
791
+ ],
792
+ }
793
+
794
+ @property
795
+ def interested_topics(self):
796
+ """The message topics that the mutation operator is interested in."""
797
+ return [TerminatorMessageTopics.GENERATION]
798
+
799
+ def __init__(
800
+ self,
801
+ *,
802
+ problem: Problem,
803
+ seed: int,
804
+ max_generations: int,
805
+ verbosity: int,
806
+ publisher: Publisher,
807
+ mutation_probability: float | None = None,
808
+ b: float = 5.0, # decay parameter
809
+ ):
810
+ """Initialize a Non-uniform mutation operator.
811
+
812
+ Args:
813
+ problem (Problem): The optimization problem definition.
814
+ seed (int): Random number generator seed for reproducibility.
815
+ mutation_probability (float | None): Probability of mutating each
816
+ gene. If None, defaults to 1 / number of variables.
817
+ b (float): Non-uniform mutation decay parameter. Higher values cause
818
+ faster reduction in mutation strength over generations.
819
+ max_generations (int): Maximum number of generations in the evolutionary run. Used to scale mutation decay.
820
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
821
+ messages are provided at each verbosity level. Recommended value is 1.
822
+ publisher (Publisher): The publisher to which the operator will send messages.
823
+ """
824
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
825
+ self.rng = np.random.default_rng(seed)
826
+ self.seed = seed
827
+ self.b = b
828
+ self.current_generation = 0
829
+ self.max_generations = max_generations
830
+ self.mutation_probability = (
831
+ 1 / len(self.variable_symbols) if mutation_probability is None else mutation_probability
832
+ )
833
+
834
+ def _mutate_value(self, x, lower_bound, upper_bound, mutation_threshold=0.5):
835
+ """Apply non-uniform mutation to a single float value.
836
+
837
+ Args:
838
+ x (float): The current value of the gene to be mutated.
839
+ lower_bound (float): The lower bound of the gene.
840
+ upper_bound (float): The upper bound of the gene.
841
+ mutation_threshold (float): The mutation threshold. Defaults to 0.5.
842
+
843
+ Returns:
844
+ float: The mutated gene value, clipped within the bounds [l, u].
845
+ """
846
+ r = self.rng.uniform(0, 1) # Random number to choose direction
847
+ t = self.current_generation
848
+ max_generations = self.max_generations
849
+ b = self.b
850
+
851
+ u_rand = self.rng.uniform(0, 1) # Random number for mutation strength
852
+ tau = (1 - t / max_generations) ** b
853
+
854
+ if r <= mutation_threshold:
855
+ y = upper_bound - x
856
+ delta = y * (1 - u_rand**tau)
857
+ xm = x + delta
858
+ else:
859
+ y = x - lower_bound
860
+ delta = y * (1 - u_rand**tau)
861
+ xm = x - delta
862
+
863
+ return np.clip(xm, lower_bound, upper_bound)
864
+
865
+ def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
866
+ """Perform non-uniform mutation.
867
+
868
+ Args:
869
+ offsprings (pl.DataFrame): The current offspring population to
870
+ mutate. Each row corresponds to one individual.
871
+ parents (pl.DataFrame): The parent population (not used in mutation but passed for interface consistency).
872
+
873
+ Returns:
874
+ pl.DataFrame: A new offspring population with mutated values applied. Returned as a Polars DataFrame.
875
+ """
876
+ self.offspring_original = copy.copy(offsprings)
877
+ self.parents = parents
878
+
879
+ population = offsprings.to_numpy(writable=True).astype(float)
880
+
881
+ for i in range(population.shape[0]):
882
+ for j, var in enumerate(self.problem.variables):
883
+ if self.rng.random() < self.mutation_probability:
884
+ x = population[i, j]
885
+ lower_bound, upper_bound = var.lowerbound, var.upperbound
886
+ if var.variable_type in [VariableTypeEnum.binary, VariableTypeEnum.integer]:
887
+ population[i, j] = round(self._mutate_value(x, lower_bound, upper_bound))
888
+ else:
889
+ population[i, j] = self._mutate_value(x, lower_bound, upper_bound)
890
+
891
+ self.offspring = pl.from_numpy(population, schema=self.variable_symbols).cast(pl.Float64)
892
+ self.notify()
893
+
894
+ return self.offspring
895
+
896
+ def update(self, message: Message):
897
+ """Update current generation (used to reduce mutation strength over time)."""
898
+ if not isinstance(message.topic, TerminatorMessageTopics):
899
+ return
900
+ if not isinstance(message.value, int):
901
+ return
902
+ if message.topic != TerminatorMessageTopics.GENERATION:
903
+ raise ValueError(
904
+ f"Expected message topic {TerminatorMessageTopics.GENERATION}, got {message.topic}."
905
+ )
906
+ self.current_generation = message.value
907
+
908
+ def state(self) -> Sequence[Message]:
909
+ """Return state messages."""
910
+ if self.verbosity == 0:
911
+ return []
912
+ return [
913
+ FloatMessage(
914
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
915
+ source=self.__class__.__name__,
916
+ value=self.mutation_probability,
917
+ ),
918
+ ]
919
+
920
+
921
+ class SelfAdaptiveGaussianMutation(BaseMutation):
922
+ """Self-adaptive Gaussian mutation for real-coded evolutionary algorithms.
923
+
924
+ Evolves both solution vector and mutation step sizes (strategy parameters).
925
+ """
926
+
927
+ @property
928
+ def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
929
+ """The message topics provided by the mutation operator."""
930
+ return {
931
+ 0: [],
932
+ 1: [
933
+ MutationMessageTopics.MUTATION_PROBABILITY,
934
+ ],
935
+ 2: [
936
+ MutationMessageTopics.MUTATION_PROBABILITY,
937
+ MutationMessageTopics.OFFSPRING_ORIGINAL,
938
+ MutationMessageTopics.OFFSPRINGS,
939
+ ],
940
+ }
941
+
942
+ @property
943
+ def interested_topics(self):
944
+ """The message topics that the mutation operator is interested in."""
945
+ return []
946
+
947
+ def __init__(
948
+ self,
949
+ *,
950
+ problem: Problem,
951
+ seed: int,
952
+ verbosity: int,
953
+ publisher: Publisher,
954
+ mutation_probability: float | None = None,
955
+ ):
956
+ """Initialize the self-adaptive Gaussian mutation operator.
957
+
958
+ Args:
959
+ problem (Problem): The optimization problem definition, including variable bounds and types.
960
+ seed (int): Seed for the random number generator to ensure reproducibility.
961
+ mutation_probability (float | None): Probability of mutating each gene.
962
+ If None, it defaults to 1 divided by the number of variables.
963
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
964
+ messages are provided at each verbosity level. Recommended value is 1.
965
+ publisher (Publisher): The publisher to which the operator will send messages.
966
+
967
+ Attributes:
968
+ rng (Generator): NumPy random number generator initialized with the given seed.
969
+ seed (int): The seed used for reproducibility.
970
+ num_vars (int): Number of variables in the problem.
971
+ mutation_probability (float): Probability of mutating each gene.
972
+ tau_prime (float): Global learning rate, used in step size adaptation.
973
+ tau (float): Local learning rate, used in step size adaptation.
974
+ """
975
+ super().__init__(problem=problem, verbosity=verbosity, publisher=publisher)
976
+
977
+ self.rng = np.random.default_rng(seed)
978
+ self.seed = seed
979
+ self.num_vars = len(self.variable_symbols)
980
+
981
+ self.mutation_probability = 1 / self.num_vars if mutation_probability is None else mutation_probability
982
+
983
+ self.tau_prime = 1 / np.sqrt(2 * self.num_vars)
984
+ self.tau = 1 / np.sqrt(2 * np.sqrt(self.num_vars))
985
+
986
+ def do(
987
+ self,
988
+ offsprings: pl.DataFrame,
989
+ parents: pl.DataFrame,
990
+ step_sizes: np.ndarray | None = None,
991
+ ) -> tuple[pl.DataFrame, np.ndarray]:
992
+ """Apply self-adaptive Gaussian mutation.
993
+
994
+ Args:
995
+ offsprings (pl.DataFrame): Current offspring population.
996
+ parents (pl.DataFrame): Parent population.
997
+ step_sizes (np.ndarray | None): Step sizes for each gene of each individual.
998
+
999
+ Returns:
1000
+ tuple:
1001
+ - Mutated offspring population as a Polars DataFrame.
1002
+ - Updated step sizes as a NumPy array.
1003
+ """
1004
+ self.offspring_original = offsprings
1005
+ self.parents = parents
1006
+
1007
+ offspring_array = offsprings.to_numpy(writable=True).astype(float)
1008
+
1009
+ if step_sizes is None:
1010
+ step_sizes = np.full_like(offspring_array, fill_value=0.1)
1011
+
1012
+ new_offspring, new_eta = self._mutation(offspring_array, step_sizes)
1013
+
1014
+ mutated_df = pl.from_numpy(new_offspring, schema=self.variable_symbols).cast(pl.Float64)
1015
+ self.offspring = mutated_df
1016
+ self.notify()
1017
+
1018
+ return mutated_df, new_eta
1019
+
1020
+ def _mutation(self, variables: np.ndarray, eta: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
1021
+ """Perform the self-adaptive mutation.
1022
+
1023
+ Args:
1024
+ variables (np.ndarray): Current offspring population as a NumPy array.
1025
+ eta (np.ndarray): Current step sizes for mutation.
1026
+
1027
+ Returns:
1028
+ tuple[np.ndarray, np.ndarray]: Mutated population and updated step sizes.
1029
+ """
1030
+ new_variables = variables.copy()
1031
+ new_eta = eta.copy()
1032
+
1033
+ for i in range(variables.shape[0]):
1034
+ common_noise = self.rng.normal()
1035
+ for j in range(variables.shape[1]):
1036
+ if self.rng.random() < self.mutation_probability:
1037
+ rnd_number = self.rng.normal() # random number in the interval [0, 1]
1038
+ new_eta[i, j] *= np.exp(self.tau_prime * common_noise + self.tau * rnd_number)
1039
+ new_variables[i, j] += new_eta[i, j] * rnd_number
1040
+
1041
+ return new_variables, new_eta
1042
+
1043
+ def update(self, *_, **__):
1044
+ """Do nothing."""
1045
+
1046
+ def state(self) -> Sequence[Message]:
1047
+ """Return the state of the mutation operator."""
1048
+ if self.offspring_original is None or self.offspring is None:
1049
+ return []
1050
+ if self.verbosity == 0:
1051
+ return []
1052
+ if self.verbosity == 1:
1053
+ return [
1054
+ FloatMessage(
1055
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
1056
+ source=self.__class__.__name__,
1057
+ value=self.mutation_probability,
1058
+ ),
1059
+ ]
1060
+ # verbosity == 2
1061
+ return [
1062
+ PolarsDataFrameMessage(
1063
+ topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
1064
+ source=self.__class__.__name__,
1065
+ value=self.offspring_original,
1066
+ ),
1067
+ PolarsDataFrameMessage(
1068
+ topic=MutationMessageTopics.PARENTS,
1069
+ source=self.__class__.__name__,
1070
+ value=self.parents,
1071
+ ),
1072
+ PolarsDataFrameMessage(
1073
+ topic=MutationMessageTopics.OFFSPRINGS,
1074
+ source=self.__class__.__name__,
1075
+ value=self.offspring,
1076
+ ),
1077
+ FloatMessage(
1078
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
1079
+ source=self.__class__.__name__,
1080
+ value=self.mutation_probability,
1081
+ ),
1082
+ ]
1083
+
1084
+
1085
+ class PowerMutation(BaseMutation):
1086
+ """Implements the Power Mutation (PM) operator for real and integer variables."""
1087
+
1088
+ @property
1089
+ def provided_topics(self) -> dict[int, Sequence[MutationMessageTopics]]:
1090
+ """The message topics provided by the mutation operator."""
1091
+ return {
1092
+ 0: [],
1093
+ 1: [MutationMessageTopics.MUTATION_PROBABILITY],
1094
+ 2: [
1095
+ MutationMessageTopics.MUTATION_PROBABILITY,
1096
+ MutationMessageTopics.OFFSPRING_ORIGINAL,
1097
+ MutationMessageTopics.OFFSPRINGS,
1098
+ ],
1099
+ }
1100
+
1101
+ @property
1102
+ def interested_topics(self):
1103
+ """The message topics that the mutation operator listens to (none in this case)."""
1104
+ return []
1105
+
1106
+ def __init__(
1107
+ self,
1108
+ *,
1109
+ problem: Problem,
1110
+ seed: int,
1111
+ verbosity: int,
1112
+ publisher: Publisher,
1113
+ p: float = 1.5,
1114
+ mutation_probability: float | None = None,
1115
+ ):
1116
+ """Initialize the PowerMutation operator.
1117
+
1118
+ Args:
1119
+ problem (Problem): The problem definition containing variable bounds and types.
1120
+ seed (int): Random seed for reproducibility.
1121
+ p (float): Power distribution parameter. Controls the perturbation magnitude. Default is 1.5.
1122
+ mutation_probability (float | None): Per-variable mutation probability. Defaults to 1/n.
1123
+ verbosity (int): The verbosity level of the operator. See the `provided_topics` attribute to see what
1124
+ messages are provided at each verbosity level. Recommended value is 1.
1125
+ publisher (Publisher): The publisher to which the operator will send messages.
1126
+ """
1127
+ super().__init__(problem, verbosity=verbosity, publisher=publisher)
1128
+ self.p = p
1129
+ self.mutation_probability = (
1130
+ mutation_probability if mutation_probability is not None else 1 / len(self.variable_symbols)
1131
+ )
1132
+ self.rng = np.random.default_rng(seed)
1133
+ self.seed = seed
1134
+ self.offspring_original: pl.DataFrame
1135
+ self.parents: pl.DataFrame
1136
+ self.offspring: pl.DataFrame
1137
+
1138
+ def do(self, offsprings: pl.DataFrame, parents: pl.DataFrame) -> pl.DataFrame:
1139
+ """Apply Power Mutation to the given offspring population.
1140
+
1141
+ Args:
1142
+ offsprings (pl.DataFrame): The offspring population to mutate.
1143
+ parents (pl.DataFrame): The parent population
1144
+
1145
+ Returns:
1146
+ pl.DataFrame: Mutated offspring population.
1147
+ """
1148
+ self.offspring_original = copy.copy(offsprings)
1149
+ self.parents = parents
1150
+
1151
+ if self.mutation_probability == 0.0:
1152
+ self.offspring = offsprings.clone()
1153
+ self.notify()
1154
+ return self.offspring
1155
+
1156
+ population = offsprings.to_numpy(writable=True).astype(float)
1157
+ mutation_mask = self.rng.random(population.shape) < self.mutation_probability
1158
+ mutated = population.copy()
1159
+
1160
+ for i, var in enumerate(self.problem.variables):
1161
+ lower_bound, upper_bound = var.lowerbound, var.upperbound
1162
+ x_i = population[:, i]
1163
+
1164
+ u_i = self.rng.random(len(x_i)) # uniform random number
1165
+ s_i = u_i ** (1 / self.p) # random number that follows the power distribution
1166
+
1167
+ r_i = self.rng.random(len(x_i)) # another uniform random number
1168
+ direction = ((x_i - lower_bound) / (upper_bound - lower_bound)) < r_i # used as condition
1169
+
1170
+ xi_mutated = np.where(direction, x_i - s_i * (x_i - lower_bound), x_i + s_i * (upper_bound - x_i))
1171
+
1172
+ # Apply mutation based on mask
1173
+ mutated[:, i] = np.where(mutation_mask[:, i], xi_mutated, x_i)
1174
+
1175
+ # Convert back to DataFrame
1176
+ self.offspring = pl.from_numpy(mutated, schema=self.variable_symbols).select(pl.all()).cast(pl.Float64)
1177
+ self.notify()
1178
+
1179
+ return self.offspring
1180
+
1181
+ def update(self, *_, **__):
1182
+ """No update logic needed."""
1183
+
1184
+ def state(self) -> Sequence[Message]:
1185
+ """Return mutation-related state messages based on verbosity level.
1186
+
1187
+ Returns:
1188
+ List of messages reporting mutation probability, input, and output (at higher verbosity).
1189
+ """
1190
+ if self.offspring_original is None or self.offspring is None or self.verbosity == 0:
1191
+ return []
1192
+
1193
+ if self.verbosity == 1:
1194
+ return [
1195
+ FloatMessage(
1196
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
1197
+ source=self.__class__.__name__,
1198
+ value=self.mutation_probability,
1199
+ ),
1200
+ ]
1201
+
1202
+ # Verbosity == 2
1203
+ return [
1204
+ PolarsDataFrameMessage(
1205
+ topic=MutationMessageTopics.OFFSPRING_ORIGINAL,
1206
+ source=self.__class__.__name__,
1207
+ value=self.offspring_original,
1208
+ ),
1209
+ PolarsDataFrameMessage(
1210
+ topic=MutationMessageTopics.PARENTS,
1211
+ source=self.__class__.__name__,
1212
+ value=self.parents,
1213
+ ),
1214
+ PolarsDataFrameMessage(
1215
+ topic=MutationMessageTopics.OFFSPRINGS,
1216
+ source=self.__class__.__name__,
1217
+ value=self.offspring,
1218
+ ),
1219
+ FloatMessage(
1220
+ topic=MutationMessageTopics.MUTATION_PROBABILITY,
1221
+ source=self.__class__.__name__,
1222
+ value=self.mutation_probability,
1223
+ ),
1224
+ ]