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,1778 @@
1
+ """The base class for selection operators.
2
+
3
+ Some operators should be rewritten.
4
+ TODO:@light-weaver
5
+ """
6
+
7
+ import warnings
8
+ from abc import abstractmethod
9
+ from collections.abc import Sequence
10
+ from enum import StrEnum
11
+ from itertools import combinations
12
+ from typing import Callable, Literal, TypeVar
13
+
14
+ import numpy as np
15
+ import polars as pl
16
+ from numba import njit
17
+ from pydantic import BaseModel, ConfigDict, Field
18
+ from scipy.special import comb
19
+ from scipy.stats.qmc import LatinHypercube
20
+
21
+ from desdeo.problem import Problem
22
+ from desdeo.tools import get_corrected_ideal_and_nadir
23
+ from desdeo.tools.indicators_binary import self_epsilon
24
+ from desdeo.tools.message import (
25
+ Array2DMessage,
26
+ DictMessage,
27
+ Message,
28
+ NumpyArrayMessage,
29
+ PolarsDataFrameMessage,
30
+ SelectorMessageTopics,
31
+ TerminatorMessageTopics,
32
+ )
33
+ from desdeo.tools.non_dominated_sorting import fast_non_dominated_sort
34
+ from desdeo.tools.patterns import Publisher, Subscriber
35
+
36
+ SolutionType = TypeVar("SolutionType", list, pl.DataFrame)
37
+
38
+
39
+ class BaseSelector(Subscriber):
40
+ """A base class for selection operators."""
41
+
42
+ def __init__(self, problem: Problem, verbosity: int, publisher: Publisher, seed: int = 0):
43
+ """Initialize a selection operator."""
44
+ super().__init__(verbosity=verbosity, publisher=publisher)
45
+ self.problem = problem
46
+ self.variable_symbols = [x.symbol for x in problem.get_flattened_variables()]
47
+ self.objective_symbols = [x.symbol for x in problem.objectives]
48
+ self.maximization_mult = {x.symbol: -1 if x.maximize else 1 for x in problem.objectives}
49
+
50
+ if problem.scalarization_funcs is None:
51
+ self.target_symbols = [f"{x.symbol}_min" for x in problem.objectives]
52
+ try:
53
+ ideal, nadir = get_corrected_ideal_and_nadir(problem) # This is for the minimized problem
54
+ self.ideal = np.array([ideal[x.symbol] for x in problem.objectives])
55
+ self.nadir = np.array([nadir[x.symbol] for x in problem.objectives]) if nadir is not None else None
56
+ except ValueError: # in case the ideal and nadir are not provided
57
+ self.ideal = None
58
+ self.nadir = None
59
+ else:
60
+ self.target_symbols = [x.symbol for x in problem.scalarization_funcs if x.symbol is not None]
61
+ self.ideal: np.ndarray | None = None
62
+ self.nadir: np.ndarray | None = None
63
+ if problem.constraints is None:
64
+ self.constraints_symbols = None
65
+ else:
66
+ self.constraints_symbols = [x.symbol for x in problem.constraints]
67
+ self.num_dims = len(self.target_symbols)
68
+ self.seed = seed
69
+ self.rng = np.random.default_rng(seed)
70
+
71
+ @abstractmethod
72
+ def do(
73
+ self,
74
+ parents: tuple[SolutionType, pl.DataFrame],
75
+ offsprings: tuple[SolutionType, pl.DataFrame],
76
+ ) -> tuple[SolutionType, pl.DataFrame]:
77
+ """Perform the selection operation.
78
+
79
+ Args:
80
+ parents (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
81
+ The second element is the objective values, targets, and constraint violations.
82
+ offsprings (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
83
+ The second element is the objective values, targets, and constraint violations.
84
+
85
+ Returns:
86
+ tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values,
87
+ targets, and constraint violations.
88
+ """
89
+
90
+
91
+ class ReferenceVectorOptions(BaseModel):
92
+ """Pydantic model for Reference Vector arguments."""
93
+
94
+ model_config = ConfigDict(use_attribute_docstrings=True)
95
+
96
+ adaptation_frequency: int = Field(default=0)
97
+ """Number of generations between reference vector adaptation. If set to 0, no adaptation occurs. Defaults to 0.
98
+ Only used if no preference is provided."""
99
+ creation_type: Literal["simplex", "s_energy"] = Field(default="simplex")
100
+ """The method for creating reference vectors. Defaults to "simplex".
101
+ Currently only "simplex" is implemented. Future versions will include "s_energy".
102
+
103
+ If set to "simplex", the reference vectors are created using the simplex lattice design method.
104
+ This method is generates distributions with specific numbers of reference vectors.
105
+ Check: https://www.itl.nist.gov/div898/handbook/pri/section5/pri542.htm for more information.
106
+ If set to "s_energy", the reference vectors are created using the Riesz s-energy criterion. This method is used to
107
+ distribute an arbitrary number of reference vectors in the objective space while minimizing the s-energy.
108
+ Currently not implemented.
109
+ """
110
+ vector_type: Literal["spherical", "planar"] = Field(default="spherical")
111
+ """The method for normalizing the reference vectors. Defaults to "spherical"."""
112
+ lattice_resolution: int | None = None
113
+ """Number of divisions along an axis when creating the simplex lattice. This is not required/used for the "s_energy"
114
+ method. If not specified, the lattice resolution is calculated based on the `number_of_vectors`. If "spherical" is
115
+ selected as the `vector_type`, this value overrides the `number_of_vectors`.
116
+ """
117
+ number_of_vectors: int = 200
118
+ """Number of reference vectors to be created. If "simplex" is selected as the `creation_type`, then the closest
119
+ `lattice_resolution` is calculated based on this value. If "s_energy" is selected, then this value is used directly.
120
+ Note that if neither `lattice_resolution` nor `number_of_vectors` is specified, the number of vectors defaults to
121
+ 200. Overridden if "spherical" is selected as the `vector_type` and `lattice_resolution` is provided.
122
+ """
123
+ adaptation_distance: float = Field(default=0.2)
124
+ """Distance parameter for the interactive adaptation methods. Defaults to 0.2."""
125
+ reference_point: dict[str, float] | None = Field(default=None)
126
+ """The reference point for interactive adaptation."""
127
+ preferred_solutions: dict[str, list[float]] | None = Field(default=None)
128
+ """The preferred solutions for interactive adaptation."""
129
+ non_preferred_solutions: dict[str, list[float]] | None = Field(default=None)
130
+ """The non-preferred solutions for interactive adaptation."""
131
+ preferred_ranges: dict[str, list[float]] | None = Field(default=None)
132
+ """The preferred ranges for interactive adaptation."""
133
+
134
+
135
+ class BaseDecompositionSelector(BaseSelector):
136
+ """Base class for decomposition based selection operators."""
137
+
138
+ def __init__(
139
+ self,
140
+ problem: Problem,
141
+ reference_vector_options: ReferenceVectorOptions,
142
+ verbosity: int,
143
+ publisher: Publisher,
144
+ invert_reference_vectors: bool = False,
145
+ seed: int = 0,
146
+ ):
147
+ super().__init__(problem, verbosity=verbosity, publisher=publisher, seed=seed)
148
+ self.reference_vector_options = reference_vector_options
149
+ self.invert_reference_vectors = invert_reference_vectors
150
+ self.reference_vectors: np.ndarray
151
+ self.reference_vectors_initial: np.ndarray
152
+
153
+ if self.reference_vector_options.creation_type == "s_energy":
154
+ raise NotImplementedError("Riesz s-energy criterion is not yet implemented.")
155
+
156
+ self._create_simplex()
157
+
158
+ if self.reference_vector_options.reference_point:
159
+ corrected_rp = np.array(
160
+ [
161
+ self.reference_vector_options.reference_point[x] * self.maximization_mult[x]
162
+ for x in self.objective_symbols
163
+ ]
164
+ )
165
+ self.interactive_adapt_3(
166
+ corrected_rp,
167
+ translation_param=self.reference_vector_options.adaptation_distance,
168
+ )
169
+ elif self.reference_vector_options.preferred_solutions:
170
+ corrected_sols = np.array(
171
+ [
172
+ np.array(self.reference_vector_options.preferred_solutions[x]) * self.maximization_mult[x]
173
+ for x in self.objective_symbols
174
+ ]
175
+ ).T
176
+ self.interactive_adapt_1(
177
+ corrected_sols,
178
+ translation_param=self.reference_vector_options.adaptation_distance,
179
+ )
180
+ elif self.reference_vector_options.non_preferred_solutions:
181
+ corrected_sols = np.array(
182
+ [
183
+ np.array(self.reference_vector_options.non_preferred_solutions[x]) * self.maximization_mult[x]
184
+ for x in self.objective_symbols
185
+ ]
186
+ ).T
187
+ self.interactive_adapt_2(
188
+ corrected_sols,
189
+ predefined_distance=self.reference_vector_options.adaptation_distance,
190
+ ord=2 if self.reference_vector_options.vector_type == "spherical" else 1,
191
+ )
192
+ elif self.reference_vector_options.preferred_ranges:
193
+ corrected_ranges = np.array(
194
+ [
195
+ np.array(self.reference_vector_options.preferred_ranges[x]) * self.maximization_mult[x]
196
+ for x in self.objective_symbols
197
+ ]
198
+ ).T
199
+ self.interactive_adapt_4(
200
+ corrected_ranges,
201
+ )
202
+
203
+ def _create_simplex(self):
204
+ """Create the reference vectors using simplex lattice design."""
205
+
206
+ def approx_lattice_resolution(number_of_vectors: int, num_dims: int) -> int:
207
+ """Approximate the lattice resolution based on the number of vectors."""
208
+ temp_lattice_resolution = 0
209
+ while True:
210
+ temp_lattice_resolution += 1
211
+ temp_number_of_vectors = comb(
212
+ temp_lattice_resolution + num_dims - 1,
213
+ num_dims - 1,
214
+ exact=True,
215
+ )
216
+ if temp_number_of_vectors > number_of_vectors:
217
+ break
218
+ return temp_lattice_resolution - 1
219
+
220
+ if self.reference_vector_options.lattice_resolution:
221
+ lattice_resolution = self.reference_vector_options.lattice_resolution
222
+ else:
223
+ lattice_resolution = approx_lattice_resolution(
224
+ self.reference_vector_options.number_of_vectors, num_dims=self.num_dims
225
+ )
226
+
227
+ number_of_vectors: int = comb(
228
+ lattice_resolution + self.num_dims - 1,
229
+ self.num_dims - 1,
230
+ exact=True,
231
+ )
232
+
233
+ self.reference_vector_options.number_of_vectors = number_of_vectors
234
+ self.reference_vector_options.lattice_resolution = lattice_resolution
235
+
236
+ temp1 = range(1, self.num_dims + lattice_resolution)
237
+ temp1 = np.array(list(combinations(temp1, self.num_dims - 1)))
238
+ temp2 = np.array([range(self.num_dims - 1)] * number_of_vectors)
239
+ temp = temp1 - temp2 - 1
240
+ weight = np.zeros((number_of_vectors, self.num_dims), dtype=int)
241
+ weight[:, 0] = temp[:, 0]
242
+ for i in range(1, self.num_dims - 1):
243
+ weight[:, i] = temp[:, i] - temp[:, i - 1]
244
+ weight[:, -1] = lattice_resolution - temp[:, -1]
245
+ if not self.invert_reference_vectors: # todo, this currently only exists for nsga3
246
+ self.reference_vectors = weight / lattice_resolution
247
+ else:
248
+ self.reference_vectors = 1 - (weight / lattice_resolution)
249
+ self.reference_vectors_initial = np.copy(self.reference_vectors)
250
+ self._normalize_rvs()
251
+
252
+ def _normalize_rvs(self):
253
+ """Normalize the reference vectors to a unit hypersphere."""
254
+ if self.reference_vector_options.vector_type == "spherical":
255
+ norm = np.linalg.norm(self.reference_vectors, axis=1).reshape(-1, 1)
256
+ norm[norm == 0] = np.finfo(float).eps
257
+ self.reference_vectors = np.divide(self.reference_vectors, norm)
258
+ return
259
+ if self.reference_vector_options.vector_type == "planar":
260
+ if not self.invert_reference_vectors:
261
+ norm = np.sum(self.reference_vectors, axis=1).reshape(-1, 1)
262
+ self.reference_vectors = np.divide(self.reference_vectors, norm)
263
+ return
264
+ else:
265
+ norm = np.sum(1 - self.reference_vectors, axis=1).reshape(-1, 1)
266
+ self.reference_vectors = 1 - np.divide(1 - self.reference_vectors, norm)
267
+ return
268
+ # Not needed due to pydantic validation
269
+ raise ValueError("Invalid vector type. Must be either 'spherical' or 'planar'.")
270
+
271
+ def interactive_adapt_1(self, z: np.ndarray, translation_param: float) -> None:
272
+ """Adapt reference vectors using the information about prefererred solution(s) selected by the Decision maker.
273
+
274
+ Args:
275
+ z (np.ndarray): Preferred solution(s).
276
+ translation_param (float): Parameter determining how close the reference vectors are to the central vector
277
+ **v** defined by using the selected solution(s) z.
278
+ """
279
+ if z.shape[0] == 1:
280
+ # single preferred solution
281
+ # calculate new reference vectors
282
+ self.reference_vectors = translation_param * self.reference_vectors_initial + ((1 - translation_param) * z)
283
+
284
+ else:
285
+ # multiple preferred solutions
286
+ # calculate new reference vectors for each preferred solution
287
+ values = [translation_param * self.reference_vectors_initial + ((1 - translation_param) * z_i) for z_i in z]
288
+
289
+ # combine arrays of reference vectors into a single array and update reference vectors
290
+ self.reference_vectors = np.concatenate(values)
291
+
292
+ self._normalize_rvs()
293
+ self.add_edge_vectors()
294
+
295
+ def interactive_adapt_2(self, z: np.ndarray, predefined_distance: float, ord: int) -> None:
296
+ """Adapt reference vectors by using the information about non-preferred solution(s) selected by the Decision maker.
297
+
298
+ After the Decision maker has specified non-preferred solution(s), Euclidian distance between normalized solution
299
+ vector(s) and each of the reference vectors are calculated. Those reference vectors that are **closer** than a
300
+ predefined distance are either **removed** or **re-positioned** somewhere else.
301
+
302
+ Note:
303
+ At the moment, only the **removal** of reference vectors is supported. Repositioning of the reference
304
+ vectors is **not** supported.
305
+
306
+ Note:
307
+ In case the Decision maker specifies multiple non-preferred solutions, the reference vector(s) for which the
308
+ distance to **any** of the non-preferred solutions is less than predefined distance are removed.
309
+
310
+ Note:
311
+ Future developer should implement a way for a user to say: "Remove some percentage of
312
+ objecive space/reference vectors" rather than giving a predefined distance value.
313
+
314
+ Args:
315
+ z (np.ndarray): Non-preferred solution(s).
316
+ predefined_distance (float): The reference vectors that are closer than this distance are either removed or
317
+ re-positioned somewhere else. Default value: 0.2
318
+ ord (int): Order of the norm. Default is 2, i.e., Euclidian distance.
319
+ """
320
+ # calculate L1 norm of non-preferred solution(s)
321
+ z = np.atleast_2d(z)
322
+ norm = np.linalg.norm(z, ord=ord, axis=1).reshape(np.shape(z)[0], 1)
323
+
324
+ # non-preferred solutions normalized
325
+ v_c = np.divide(z, norm)
326
+
327
+ # distances from non-preferred solution(s) to each reference vector
328
+ distances = np.array(
329
+ [
330
+ list(
331
+ map(
332
+ lambda solution: np.linalg.norm(solution - value, ord=2),
333
+ v_c,
334
+ )
335
+ )
336
+ for value in self.reference_vectors
337
+ ]
338
+ )
339
+
340
+ # find out reference vectors that are not closer than threshold value to any non-preferred solution
341
+ mask = [all(d >= predefined_distance) for d in distances]
342
+
343
+ # set those reference vectors that met previous condition as new reference vectors, drop others
344
+ self.reference_vectors = self.reference_vectors[mask]
345
+
346
+ self._normalize_rvs()
347
+ self.add_edge_vectors()
348
+
349
+ def interactive_adapt_3(self, ref_point, translation_param):
350
+ """Adapt reference vectors linearly towards a reference point. Then normalize.
351
+
352
+ The details can be found in the following paper: Hakanen, Jussi &
353
+ Chugh, Tinkle & Sindhya, Karthik & Jin, Yaochu & Miettinen, Kaisa.
354
+ (2016). Connections of Reference Vectors and Different Types of
355
+ Preference Information in Interactive Multiobjective Evolutionary
356
+ Algorithms.
357
+
358
+ Parameters
359
+ ----------
360
+ ref_point :
361
+
362
+ translation_param :
363
+ (Default value = 0.2)
364
+
365
+ """
366
+ self.reference_vectors = self.reference_vectors_initial * translation_param + (
367
+ (1 - translation_param) * ref_point
368
+ )
369
+ self._normalize_rvs()
370
+ self.add_edge_vectors()
371
+
372
+ def interactive_adapt_4(self, preferred_ranges: np.ndarray) -> None:
373
+ """Adapt reference vectors by using the information about the Decision maker's preferred range for each of the objective.
374
+
375
+ Using these ranges, Latin hypercube sampling is applied to generate m number of samples between
376
+ within these ranges, where m is the number of reference vectors. Normalized vectors constructed of these samples
377
+ are then set as new reference vectors.
378
+
379
+ Args:
380
+ preferred_ranges (np.ndarray): Preferred lower and upper bound for each of the objective function values.
381
+ """
382
+ # bounds
383
+ lower_limits = np.min(preferred_ranges, axis=0)
384
+ upper_limits = np.max(preferred_ranges, axis=0)
385
+
386
+ # generate samples using Latin hypercube sampling
387
+ lhs = LatinHypercube(d=self.num_dims, seed=self.rng)
388
+ w = lhs.random(n=self.reference_vectors_initial.shape[0])
389
+
390
+ # scale between bounds
391
+ w = w * (upper_limits - lower_limits) + lower_limits
392
+
393
+ # set new reference vectors and normalize them
394
+ self.reference_vectors = w
395
+ self._normalize_rvs()
396
+ self.add_edge_vectors()
397
+
398
+ def add_edge_vectors(self):
399
+ """Add edge vectors to the list of reference vectors.
400
+
401
+ Used to cover the entire orthant when preference information is
402
+ provided.
403
+
404
+ """
405
+ edge_vectors = np.eye(self.reference_vectors.shape[1])
406
+ self.reference_vectors = np.vstack([self.reference_vectors, edge_vectors])
407
+ self._normalize_rvs()
408
+
409
+
410
+ class ParameterAdaptationStrategy(StrEnum):
411
+ """The parameter adaptation strategies for the RVEA selector."""
412
+
413
+ GENERATION_BASED = "GENERATION_BASED" # Based on the current generation and the maximum generation.
414
+ FUNCTION_EVALUATION_BASED = (
415
+ "FUNCTION_EVALUATION_BASED" # Based on the current function evaluation and the maximum function evaluation.
416
+ )
417
+ OTHER = "OTHER" # As of yet undefined strategies.
418
+
419
+
420
+ @njit
421
+ def _rvea_selection(
422
+ fitness: np.ndarray, reference_vectors: np.ndarray, ideal: np.ndarray, partial_penalty: float, gamma: np.ndarray
423
+ ) -> tuple[np.ndarray, np.ndarray]:
424
+ """Select individuals based on their fitness and their distance to the reference vectors.
425
+
426
+ Args:
427
+ fitness (np.ndarray): The fitness values of the individuals.
428
+ reference_vectors (np.ndarray): The reference vectors.
429
+ ideal (np.ndarray): The ideal point.
430
+ partial_penalty (float): The partial penalty in APD.
431
+ gamma (np.ndarray): The angle between current and closest reference vector.
432
+
433
+ Returns:
434
+ tuple[np.ndarray, np.ndarray]: The selected individuals and their APD fitness values.
435
+ """
436
+ tranlated_fitness = fitness - ideal
437
+ num_vectors = reference_vectors.shape[0]
438
+ num_solutions = fitness.shape[0]
439
+
440
+ cos_matrix = np.zeros((num_solutions, num_vectors))
441
+
442
+ for i in range(num_solutions):
443
+ solution = tranlated_fitness[i]
444
+ norm = np.linalg.norm(solution)
445
+ for j in range(num_vectors):
446
+ cos_matrix[i, j] = np.dot(solution, reference_vectors[j]) / max(1e-10, norm) # Avoid division by zero
447
+
448
+ assignment_matrix = np.zeros((num_solutions, num_vectors), dtype=np.bool_)
449
+
450
+ for i in range(num_solutions):
451
+ assignment_matrix[i, np.argmax(cos_matrix[i])] = True
452
+
453
+ selection = np.zeros(num_solutions, dtype=np.bool_)
454
+ apd_fitness = np.zeros(num_solutions, dtype=np.float64)
455
+
456
+ for j in range(num_vectors):
457
+ min_apd = np.inf
458
+ select = -1
459
+ for i in np.where(assignment_matrix[:, j])[0]:
460
+ solution = tranlated_fitness[i]
461
+ apd = (1 + (partial_penalty * np.arccos(cos_matrix[i, j]) / gamma[j])) * np.linalg.norm(solution)
462
+ apd_fitness[i] = apd
463
+ if apd < min_apd:
464
+ min_apd = apd
465
+ select = i
466
+ selection[select] = True
467
+
468
+ return selection, apd_fitness
469
+
470
+
471
+ @njit
472
+ def _rvea_selection_constrained(
473
+ fitness: np.ndarray,
474
+ constraints: np.ndarray,
475
+ reference_vectors: np.ndarray,
476
+ ideal: np.ndarray,
477
+ partial_penalty: float,
478
+ gamma: np.ndarray,
479
+ ) -> tuple[np.ndarray, np.ndarray]:
480
+ """Select individuals based on their fitness and their distance to the reference vectors.
481
+
482
+ Args:
483
+ fitness (np.ndarray): The fitness values of the individuals.
484
+ constraints (np.ndarray): The constraint violations of the individuals.
485
+ reference_vectors (np.ndarray): The reference vectors.
486
+ ideal (np.ndarray): The ideal point.
487
+ partial_penalty (float): The partial penalty in APD.
488
+ gamma (np.ndarray): The angle between current and closest reference vector.
489
+
490
+ Returns:
491
+ tuple[np.ndarray, np.ndarray]: The selected individuals and their APD fitness values.
492
+ """
493
+ tranlated_fitness = fitness - ideal
494
+ num_vectors = reference_vectors.shape[0]
495
+ num_solutions = fitness.shape[0]
496
+
497
+ violations = np.maximum(0, constraints)
498
+
499
+ cos_matrix = np.zeros((num_solutions, num_vectors))
500
+
501
+ for i in range(num_solutions):
502
+ solution = tranlated_fitness[i]
503
+ norm = np.linalg.norm(solution)
504
+ for j in range(num_vectors):
505
+ cos_matrix[i, j] = np.dot(solution, reference_vectors[j]) / max(1e-10, norm) # Avoid division by zero
506
+
507
+ assignment_matrix = np.zeros((num_solutions, num_vectors), dtype=np.bool_)
508
+
509
+ for i in range(num_solutions):
510
+ assignment_matrix[i, np.argmax(cos_matrix[i])] = True
511
+
512
+ selection = np.zeros(num_solutions, dtype=np.bool_)
513
+ apd_fitness = np.zeros(num_solutions, dtype=np.float64)
514
+
515
+ for j in range(num_vectors):
516
+ min_apd = np.inf
517
+ min_violation = np.inf
518
+ select = -1
519
+ select_violation = -1
520
+ for i in np.where(assignment_matrix[:, j])[0]:
521
+ solution = tranlated_fitness[i]
522
+ apd = (1 + (partial_penalty * np.arccos(cos_matrix[i, j]) / gamma[j])) * np.linalg.norm(solution)
523
+ apd_fitness[i] = apd
524
+ feasible = np.all(violations[i] == 0)
525
+ current_violation = np.sum(violations[i])
526
+ if feasible:
527
+ if apd < min_apd:
528
+ min_apd = apd
529
+ select = i
530
+ elif current_violation < min_violation:
531
+ min_violation = current_violation
532
+ select_violation = i
533
+ if select != -1:
534
+ selection[select] = True
535
+ else:
536
+ selection[select_violation] = True
537
+
538
+ return selection, apd_fitness
539
+
540
+
541
+ class RVEASelector(BaseDecompositionSelector):
542
+ @property
543
+ def provided_topics(self):
544
+ return {
545
+ 0: [],
546
+ 1: [
547
+ SelectorMessageTopics.STATE,
548
+ ],
549
+ 2: [
550
+ SelectorMessageTopics.REFERENCE_VECTORS,
551
+ SelectorMessageTopics.STATE,
552
+ SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
553
+ ],
554
+ }
555
+
556
+ @property
557
+ def interested_topics(self):
558
+ return [
559
+ TerminatorMessageTopics.GENERATION,
560
+ TerminatorMessageTopics.MAX_GENERATIONS,
561
+ TerminatorMessageTopics.EVALUATION,
562
+ TerminatorMessageTopics.MAX_EVALUATIONS,
563
+ ]
564
+
565
+ def __init__(
566
+ self,
567
+ problem: Problem,
568
+ verbosity: int,
569
+ publisher: Publisher,
570
+ alpha: float = 2.0,
571
+ parameter_adaptation_strategy: ParameterAdaptationStrategy = ParameterAdaptationStrategy.GENERATION_BASED,
572
+ reference_vector_options: ReferenceVectorOptions | dict | None = None,
573
+ seed: int = 0,
574
+ ):
575
+ if parameter_adaptation_strategy not in ParameterAdaptationStrategy:
576
+ raise TypeError(f"Parameter adaptation strategy must be of Type {type(ParameterAdaptationStrategy)}")
577
+ if parameter_adaptation_strategy == ParameterAdaptationStrategy.OTHER:
578
+ raise ValueError("Other parameter adaptation strategies are not yet implemented.")
579
+
580
+ if reference_vector_options is None:
581
+ reference_vector_options = ReferenceVectorOptions()
582
+
583
+ if isinstance(reference_vector_options, dict):
584
+ reference_vector_options = ReferenceVectorOptions.model_validate(reference_vector_options)
585
+
586
+ # Just asserting correct options for RVEA
587
+ reference_vector_options.vector_type = "spherical"
588
+ if reference_vector_options.adaptation_frequency == 0:
589
+ warnings.warn(
590
+ "Adaptation frequency was set to 0. Setting it to 100 for RVEA selector. "
591
+ "Set it to 0 only if you provide preference information.",
592
+ UserWarning,
593
+ stacklevel=2,
594
+ )
595
+ reference_vector_options.adaptation_frequency = 100
596
+
597
+ super().__init__(
598
+ problem=problem,
599
+ reference_vector_options=reference_vector_options,
600
+ verbosity=verbosity,
601
+ publisher=publisher,
602
+ seed=seed,
603
+ )
604
+
605
+ self.reference_vectors_gamma: np.ndarray
606
+ self.numerator: float | None = None
607
+ self.denominator: float | None = None
608
+ self.alpha = alpha
609
+ self.selected_individuals: list | pl.DataFrame
610
+ self.selected_targets: pl.DataFrame
611
+ self.selection: list[int]
612
+ self.penalty = None
613
+ self.parameter_adaptation_strategy = parameter_adaptation_strategy
614
+ self.adapted_reference_vectors = None
615
+
616
+ def do(
617
+ self,
618
+ parents: tuple[SolutionType, pl.DataFrame],
619
+ offsprings: tuple[SolutionType, pl.DataFrame],
620
+ ) -> tuple[SolutionType, pl.DataFrame]:
621
+ """Perform the selection operation.
622
+
623
+ Args:
624
+ parents (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
625
+ The second element is the objective values, targets, and constraint violations.
626
+ offsprings (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
627
+ The second element is the objective values, targets, and constraint violations.
628
+
629
+ Returns:
630
+ tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values,
631
+ targets, and constraint violations.
632
+ """
633
+ if isinstance(parents[0], pl.DataFrame) and isinstance(offsprings[0], pl.DataFrame):
634
+ solutions = parents[0].vstack(offsprings[0])
635
+ elif isinstance(parents[0], list) and isinstance(offsprings[0], list):
636
+ solutions = parents[0] + offsprings[0]
637
+ else:
638
+ raise TypeError("The decision variables must be either a list or a polars DataFrame, not both")
639
+ if len(parents[0]) == 0:
640
+ raise RuntimeError(
641
+ "The parents population is empty. Cannot perform selection. This is a known unresolved issue."
642
+ )
643
+ alltargets = parents[1].vstack(offsprings[1])
644
+ targets = alltargets[self.target_symbols].to_numpy()
645
+ if self.constraints_symbols is None or len(self.constraints_symbols) == 0:
646
+ # No constraints :)
647
+ if self.ideal is None:
648
+ self.ideal = np.min(targets, axis=0)
649
+ else:
650
+ self.ideal = np.min(np.vstack((self.ideal, np.min(targets, axis=0))), axis=0)
651
+ self.nadir = np.max(targets, axis=0) if self.nadir is None else self.nadir
652
+ if self.adapted_reference_vectors is None:
653
+ self._adapt()
654
+ selection, _ = _rvea_selection(
655
+ fitness=targets,
656
+ reference_vectors=self.adapted_reference_vectors,
657
+ ideal=self.ideal,
658
+ partial_penalty=self._partial_penalty_factor(),
659
+ gamma=self.reference_vectors_gamma,
660
+ )
661
+ else:
662
+ # Yes constraints :(
663
+ constraints = (
664
+ parents[1][self.constraints_symbols].vstack(offsprings[1][self.constraints_symbols]).to_numpy()
665
+ )
666
+ feasible = (constraints <= 0).all(axis=1)
667
+ # Note that
668
+ if self.ideal is None:
669
+ # TODO: This breaks if there are no feasible solutions in the initial population
670
+ self.ideal = np.min(targets[feasible], axis=0)
671
+ else:
672
+ self.ideal = np.min(np.vstack((self.ideal, np.min(targets[feasible], axis=0))), axis=0)
673
+ try:
674
+ nadir = np.max(targets[feasible], axis=0)
675
+ self.nadir = nadir
676
+ except ValueError: # No feasible solution in current population
677
+ pass # Use previous nadir
678
+ if self.adapted_reference_vectors is None:
679
+ self._adapt()
680
+ selection, _ = _rvea_selection_constrained(
681
+ fitness=targets,
682
+ constraints=constraints,
683
+ reference_vectors=self.adapted_reference_vectors,
684
+ ideal=self.ideal,
685
+ partial_penalty=self._partial_penalty_factor(),
686
+ gamma=self.reference_vectors_gamma,
687
+ )
688
+
689
+ self.selection = np.where(selection)[0].tolist()
690
+ self.selected_individuals = solutions[self.selection]
691
+ self.selected_targets = alltargets[self.selection]
692
+ self.notify()
693
+ return self.selected_individuals, self.selected_targets
694
+
695
+ def _partial_penalty_factor(self) -> float:
696
+ """Calculate and return the partial penalty factor for APD calculation.
697
+
698
+ This calculation does not include the angle related terms, hence the name.
699
+ If the calculated penalty is outside [0, 1], it will round it up/down to 0/1
700
+
701
+ Returns:
702
+ float: The partial penalty factor
703
+ """
704
+ if self.numerator is None or self.denominator is None or self.denominator == 0:
705
+ raise RuntimeError("Numerator and denominator must be set before calculating the partial penalty factor.")
706
+ penalty = self.numerator / self.denominator
707
+ penalty = float(np.clip(penalty, 0, 1))
708
+ self.penalty = (penalty**self.alpha) * self.reference_vectors.shape[1]
709
+ return self.penalty
710
+
711
+ def update(self, message: Message) -> None:
712
+ """Update the parameters of the RVEA APD calculation.
713
+
714
+ Args:
715
+ message (Message): The message to update the parameters. The message should be coming from the
716
+ Terminator operator (via the Publisher).
717
+ """
718
+ if not isinstance(message.topic, TerminatorMessageTopics):
719
+ return
720
+ if not isinstance(message.value, int):
721
+ return
722
+ if self.parameter_adaptation_strategy == ParameterAdaptationStrategy.GENERATION_BASED:
723
+ if message.topic == TerminatorMessageTopics.GENERATION:
724
+ self.numerator = message.value
725
+ if (
726
+ self.reference_vector_options.adaptation_frequency > 0
727
+ and self.numerator % self.reference_vector_options.adaptation_frequency == 0
728
+ ):
729
+ self._adapt()
730
+ if message.topic == TerminatorMessageTopics.MAX_GENERATIONS:
731
+ self.denominator = message.value
732
+ elif self.parameter_adaptation_strategy == ParameterAdaptationStrategy.FUNCTION_EVALUATION_BASED:
733
+ if message.topic == TerminatorMessageTopics.EVALUATION:
734
+ self.numerator = message.value
735
+ if message.topic == TerminatorMessageTopics.MAX_EVALUATIONS:
736
+ self.denominator = message.value
737
+ return
738
+
739
+ def state(self) -> Sequence[Message]:
740
+ if self.verbosity == 0 or self.selection is None:
741
+ return []
742
+ if self.verbosity == 1:
743
+ return [
744
+ Array2DMessage(
745
+ topic=SelectorMessageTopics.REFERENCE_VECTORS,
746
+ value=self.reference_vectors.tolist(),
747
+ source=self.__class__.__name__,
748
+ ),
749
+ DictMessage(
750
+ topic=SelectorMessageTopics.STATE,
751
+ value={
752
+ "ideal": self.ideal,
753
+ "nadir": self.nadir,
754
+ "partial_penalty_factor": self._partial_penalty_factor(),
755
+ },
756
+ source=self.__class__.__name__,
757
+ ),
758
+ ] # verbosity == 2
759
+ if isinstance(self.selected_individuals, pl.DataFrame):
760
+ message = PolarsDataFrameMessage(
761
+ topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
762
+ value=pl.concat([self.selected_individuals, self.selected_targets], how="horizontal"),
763
+ source=self.__class__.__name__,
764
+ )
765
+ else:
766
+ warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
767
+ message = PolarsDataFrameMessage(
768
+ topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
769
+ value=self.selected_targets,
770
+ source=self.__class__.__name__,
771
+ )
772
+ state_verbose = [
773
+ Array2DMessage(
774
+ topic=SelectorMessageTopics.REFERENCE_VECTORS,
775
+ value=self.reference_vectors.tolist(),
776
+ source=self.__class__.__name__,
777
+ ),
778
+ DictMessage(
779
+ topic=SelectorMessageTopics.STATE,
780
+ value={
781
+ "ideal": self.ideal,
782
+ "nadir": self.nadir,
783
+ "partial_penalty_factor": self._partial_penalty_factor(),
784
+ },
785
+ source=self.__class__.__name__,
786
+ ),
787
+ # DictMessage(
788
+ # topic=SelectorMessageTopics.SELECTED_INDIVIDUALS,
789
+ # value=self.selection[0].tolist(),
790
+ # source=self.__class__.__name__,
791
+ # ),
792
+ message,
793
+ ]
794
+ return state_verbose
795
+
796
+ def _adapt(self):
797
+ self.adapted_reference_vectors = self.reference_vectors
798
+ if self.ideal is not None and self.nadir is not None:
799
+ for i in range(self.reference_vectors.shape[0]):
800
+ self.adapted_reference_vectors[i] = self.reference_vectors[i] * (self.nadir - self.ideal)
801
+ self.adapted_reference_vectors = (
802
+ self.adapted_reference_vectors / np.linalg.norm(self.adapted_reference_vectors, axis=1)[:, None]
803
+ )
804
+
805
+ self.reference_vectors_gamma = np.zeros(self.adapted_reference_vectors.shape[0])
806
+ for i in range(self.adapted_reference_vectors.shape[0]):
807
+ closest_angle = np.inf
808
+ for j in range(self.adapted_reference_vectors.shape[0]):
809
+ if i != j:
810
+ angle = np.arccos(
811
+ np.clip(np.dot(self.adapted_reference_vectors[i], self.adapted_reference_vectors[j]), -1.0, 1.0)
812
+ )
813
+ if angle < closest_angle and angle > 0:
814
+ # In cases with extreme differences in obj func ranges
815
+ # sometimes, the closest reference vectors are so close that
816
+ # the angle between them is 0 according to arccos (literally 0)
817
+ closest_angle = angle
818
+ self.reference_vectors_gamma[i] = closest_angle
819
+
820
+
821
+ @njit
822
+ def jitted_calc_perpendicular_distance(
823
+ solutions: np.ndarray, ref_dirs: np.ndarray, invert_reference_vectors: bool
824
+ ) -> np.ndarray:
825
+ """Calculate the perpendicular distance between solutions and reference directions.
826
+
827
+ Args:
828
+ solutions (np.ndarray): The normalized solutions.
829
+ ref_dirs (np.ndarray): The reference directions.
830
+ invert_reference_vectors (bool): Whether to invert the reference vectors.
831
+
832
+ Returns:
833
+ np.ndarray: The perpendicular distance matrix.
834
+ """
835
+ matrix = np.zeros((solutions.shape[0], ref_dirs.shape[0]))
836
+ for i in range(ref_dirs.shape[0]):
837
+ for j in range(solutions.shape[0]):
838
+ if invert_reference_vectors:
839
+ unit_vector = 1 - ref_dirs[i]
840
+ unit_vector = -unit_vector / np.linalg.norm(unit_vector)
841
+ else:
842
+ unit_vector = ref_dirs[i] / np.linalg.norm(ref_dirs[i])
843
+ component = ref_dirs[i] - solutions[j] - np.dot(ref_dirs[i] - solutions[j], unit_vector) * unit_vector
844
+ matrix[j, i] = np.linalg.norm(component)
845
+ return matrix
846
+
847
+
848
+ class NSGA3Selector(BaseDecompositionSelector):
849
+ """The NSGA-III selection operator, heavily based on the version of nsga3 in the pymoo package by msu-coinlab."""
850
+
851
+ @property
852
+ def provided_topics(self):
853
+ return {
854
+ 0: [],
855
+ 1: [
856
+ SelectorMessageTopics.STATE,
857
+ ],
858
+ 2: [
859
+ SelectorMessageTopics.REFERENCE_VECTORS,
860
+ SelectorMessageTopics.STATE,
861
+ SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
862
+ ],
863
+ }
864
+
865
+ @property
866
+ def interested_topics(self):
867
+ return []
868
+
869
+ def __init__(
870
+ self,
871
+ problem: Problem,
872
+ verbosity: int,
873
+ publisher: Publisher,
874
+ reference_vector_options: ReferenceVectorOptions | None = None,
875
+ invert_reference_vectors: bool = False,
876
+ seed: int = 0,
877
+ ):
878
+ """Initialize the NSGA-III selection operator.
879
+
880
+ Args:
881
+ problem (Problem): The optimization problem to be solved.
882
+ verbosity (int): The verbosity level of the operator.
883
+ publisher (Publisher): The publisher to use for communication.
884
+ reference_vector_options (ReferenceVectorOptions | None, optional): Options for the reference vectors. Defaults to None.
885
+ invert_reference_vectors (bool, optional): Whether to invert the reference vectors. Defaults to False.
886
+ seed (int, optional): The random seed to use. Defaults to 0.
887
+ """
888
+ if reference_vector_options is None:
889
+ reference_vector_options = ReferenceVectorOptions()
890
+ elif isinstance(reference_vector_options, dict):
891
+ reference_vector_options = ReferenceVectorOptions.model_validate(reference_vector_options)
892
+
893
+ # Just asserting correct options for NSGA-III
894
+ reference_vector_options.vector_type = "planar"
895
+ super().__init__(
896
+ problem,
897
+ reference_vector_options=reference_vector_options,
898
+ verbosity=verbosity,
899
+ publisher=publisher,
900
+ seed=seed,
901
+ invert_reference_vectors=invert_reference_vectors,
902
+ )
903
+ if self.constraints_symbols is not None:
904
+ raise NotImplementedError("NSGA3 selector does not support constraints. Please use a different selector.")
905
+
906
+ self.adapted_reference_vectors = None
907
+ self.worst_fitness: np.ndarray | None = None
908
+ self.extreme_points: np.ndarray | None = None
909
+ self.n_survive = self.reference_vectors.shape[0]
910
+ self.selection: list[int] | None = None
911
+ self.selected_individuals: SolutionType | None = None
912
+ self.selected_targets: pl.DataFrame | None = None
913
+
914
+ def do(
915
+ self,
916
+ parents: tuple[SolutionType, pl.DataFrame],
917
+ offsprings: tuple[SolutionType, pl.DataFrame],
918
+ ) -> tuple[SolutionType, pl.DataFrame]:
919
+ """Perform the selection operation.
920
+
921
+ Args:
922
+ parents (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
923
+ The second element is the objective values, targets, and constraint violations.
924
+ offsprings (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
925
+ The second element is the objective values, targets, and constraint violations.
926
+
927
+ Returns:
928
+ tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values,
929
+ targets, and constraint violations.
930
+ """
931
+ if isinstance(parents[0], pl.DataFrame) and isinstance(offsprings[0], pl.DataFrame):
932
+ solutions = parents[0].vstack(offsprings[0])
933
+ elif isinstance(parents[0], list) and isinstance(offsprings[0], list):
934
+ solutions = parents[0] + offsprings[0]
935
+ else:
936
+ raise TypeError("The decision variables must be either a list or a polars DataFrame, not both")
937
+ alltargets = parents[1].vstack(offsprings[1])
938
+ targets = alltargets[self.target_symbols].to_numpy()
939
+ if self.constraints_symbols is None:
940
+ constraints = None
941
+ else:
942
+ constraints = (
943
+ parents[1][self.constraints_symbols].vstack(offsprings[1][self.constraints_symbols]).to_numpy()
944
+ )
945
+ ref_dirs = self.reference_vectors
946
+
947
+ if self.ideal is None:
948
+ self.ideal = np.min(targets, axis=0)
949
+ else:
950
+ self.ideal = np.min(np.vstack((self.ideal, np.min(targets, axis=0))), axis=0)
951
+ fitness = targets
952
+ # Calculating fronts and ranks
953
+ # fronts, dl, dc, rank = nds(fitness)
954
+ fronts = fast_non_dominated_sort(fitness)
955
+ fronts = [np.where(fronts[i])[0] for i in range(len(fronts))]
956
+ non_dominated = fronts[0]
957
+
958
+ if self.worst_fitness is None:
959
+ self.worst_fitness = np.max(fitness, axis=0)
960
+ else:
961
+ self.worst_fitness = np.amax(np.vstack((self.worst_fitness, fitness)), axis=0)
962
+
963
+ # Calculating worst points
964
+ worst_of_population = np.amax(fitness, axis=0)
965
+ worst_of_front = np.max(fitness[non_dominated, :], axis=0)
966
+ self.extreme_points = self.get_extreme_points_c(
967
+ fitness[non_dominated, :], self.ideal, extreme_points=self.extreme_points
968
+ )
969
+ self.nadir_point = nadir_point = self.get_nadir_point(
970
+ self.extreme_points,
971
+ self.ideal,
972
+ self.worst_fitness,
973
+ worst_of_population,
974
+ worst_of_front,
975
+ )
976
+
977
+ # Finding individuals in first 'n' fronts
978
+ selection = np.asarray([], dtype=int)
979
+ for front_id in range(len(fronts)):
980
+ if len(np.concatenate(fronts[: front_id + 1])) < self.n_survive:
981
+ continue
982
+ else:
983
+ fronts = fronts[: front_id + 1]
984
+ selection = np.concatenate(fronts)
985
+ break
986
+ F = fitness[selection]
987
+
988
+ last_front = fronts[-1]
989
+
990
+ # Selecting individuals from the last acceptable front.
991
+ if len(selection) > self.n_survive:
992
+ niche_of_individuals, dist_to_niche = self.associate_to_niches(F, ref_dirs, self.ideal, nadir_point)
993
+ # if there is only one front
994
+ if len(fronts) == 1:
995
+ n_remaining = self.n_survive
996
+ until_last_front = np.array([], dtype=int)
997
+ niche_count = np.zeros(len(ref_dirs), dtype=int)
998
+
999
+ # if some individuals already survived
1000
+ else:
1001
+ until_last_front = np.concatenate(fronts[:-1])
1002
+ id_until_last_front = list(range(len(until_last_front)))
1003
+ niche_count = self.calc_niche_count(len(ref_dirs), niche_of_individuals[id_until_last_front])
1004
+ n_remaining = self.n_survive - len(until_last_front)
1005
+
1006
+ last_front_selection_id = list(range(len(until_last_front), len(selection)))
1007
+ if np.any(selection[last_front_selection_id] != last_front):
1008
+ print("error!!!")
1009
+ selected_from_last_front = self.niching(
1010
+ fitness[last_front, :],
1011
+ n_remaining,
1012
+ niche_count,
1013
+ niche_of_individuals[last_front_selection_id],
1014
+ dist_to_niche[last_front_selection_id],
1015
+ )
1016
+ final_selection = np.concatenate((until_last_front, last_front[selected_from_last_front]))
1017
+ if self.extreme_points is None:
1018
+ print("Error")
1019
+ if final_selection is None:
1020
+ print("Error")
1021
+ else:
1022
+ final_selection = selection
1023
+
1024
+ self.selection = final_selection.tolist()
1025
+ if isinstance(solutions, pl.DataFrame) and self.selection is not None:
1026
+ self.selected_individuals = solutions[self.selection]
1027
+ elif isinstance(solutions, list) and self.selection is not None:
1028
+ self.selected_individuals = [solutions[i] for i in self.selection]
1029
+ else:
1030
+ raise RuntimeError("Something went wrong with the selection")
1031
+ self.selected_targets = alltargets[self.selection]
1032
+
1033
+ self.notify()
1034
+ return self.selected_individuals, self.selected_targets
1035
+
1036
+ def get_extreme_points_c(self, F, ideal_point, extreme_points=None):
1037
+ """Taken from pymoo"""
1038
+ # calculate the asf which is used for the extreme point decomposition
1039
+ asf = np.eye(F.shape[1])
1040
+ asf[asf == 0] = 1e6
1041
+
1042
+ # add the old extreme points to never loose them for normalization
1043
+ _F = F
1044
+ if extreme_points is not None:
1045
+ _F = np.concatenate([extreme_points, _F], axis=0)
1046
+
1047
+ # use __F because we substitute small values to be 0
1048
+ __F = _F - ideal_point
1049
+ __F[__F < 1e-3] = 0
1050
+
1051
+ # update the extreme points for the normalization having the highest asf value
1052
+ # each
1053
+ F_asf = np.max(__F * asf[:, None, :], axis=2)
1054
+ I = np.argmin(F_asf, axis=1)
1055
+ extreme_points = _F[I, :]
1056
+ return extreme_points
1057
+
1058
+ def get_nadir_point(
1059
+ self,
1060
+ extreme_points,
1061
+ ideal_point,
1062
+ worst_point,
1063
+ worst_of_front,
1064
+ worst_of_population,
1065
+ ):
1066
+ LinAlgError = np.linalg.LinAlgError
1067
+ try:
1068
+ # find the intercepts using gaussian elimination
1069
+ M = extreme_points - ideal_point
1070
+ b = np.ones(extreme_points.shape[1])
1071
+ plane = np.linalg.solve(M, b)
1072
+ intercepts = 1 / plane
1073
+
1074
+ nadir_point = ideal_point + intercepts
1075
+
1076
+ if not np.allclose(np.dot(M, plane), b) or np.any(intercepts <= 1e-6) or np.any(nadir_point > worst_point):
1077
+ raise LinAlgError()
1078
+
1079
+ except LinAlgError:
1080
+ nadir_point = worst_of_front
1081
+
1082
+ b = nadir_point - ideal_point <= 1e-6
1083
+ nadir_point[b] = worst_of_population[b]
1084
+ return nadir_point
1085
+
1086
+ def niching(self, F, n_remaining, niche_count, niche_of_individuals, dist_to_niche):
1087
+ survivors = []
1088
+
1089
+ # boolean array of elements that are considered for each iteration
1090
+ mask = np.full(F.shape[0], True)
1091
+
1092
+ while len(survivors) < n_remaining:
1093
+ # all niches where new individuals can be assigned to
1094
+ next_niches_list = np.unique(niche_of_individuals[mask])
1095
+
1096
+ # pick a niche with minimum assigned individuals - break tie if necessary
1097
+ next_niche_count = niche_count[next_niches_list]
1098
+ next_niche = np.where(next_niche_count == next_niche_count.min())[0]
1099
+ next_niche = next_niches_list[next_niche]
1100
+ next_niche = next_niche[self.rng.integers(0, len(next_niche))]
1101
+
1102
+ # indices of individuals that are considered and assign to next_niche
1103
+ next_ind = np.where(np.logical_and(niche_of_individuals == next_niche, mask))[0]
1104
+
1105
+ # shuffle to break random tie (equal perp. dist) or select randomly
1106
+ self.rng.shuffle(next_ind)
1107
+
1108
+ if niche_count[next_niche] == 0:
1109
+ next_ind = next_ind[np.argmin(dist_to_niche[next_ind])]
1110
+ else:
1111
+ # already randomized through shuffling
1112
+ next_ind = next_ind[0]
1113
+
1114
+ mask[next_ind] = False
1115
+ survivors.append(int(next_ind))
1116
+
1117
+ niche_count[next_niche] += 1
1118
+
1119
+ return survivors
1120
+
1121
+ def associate_to_niches(self, F, ref_dirs, ideal_point, nadir_point, utopian_epsilon=0.0):
1122
+ utopian_point = ideal_point - utopian_epsilon
1123
+
1124
+ denom = nadir_point - utopian_point
1125
+ denom[denom == 0] = 1e-12
1126
+
1127
+ # normalize by ideal point and intercepts
1128
+ N = (F - utopian_point) / denom
1129
+ # dist_matrix = self.calc_perpendicular_distance(N, ref_dirs)
1130
+ dist_matrix = jitted_calc_perpendicular_distance(N, ref_dirs, self.invert_reference_vectors)
1131
+
1132
+ niche_of_individuals = np.argmin(dist_matrix, axis=1)
1133
+ dist_to_niche = dist_matrix[np.arange(F.shape[0]), niche_of_individuals]
1134
+
1135
+ return niche_of_individuals, dist_to_niche
1136
+
1137
+ def calc_niche_count(self, n_niches, niche_of_individuals):
1138
+ niche_count = np.zeros(n_niches, dtype=int)
1139
+ index, count = np.unique(niche_of_individuals, return_counts=True)
1140
+ niche_count[index] = count
1141
+ return niche_count
1142
+
1143
+ def calc_perpendicular_distance(self, N, ref_dirs):
1144
+ if self.invert_reference_vectors:
1145
+ u = np.tile(-ref_dirs, (len(N), 1))
1146
+ v = np.repeat(1 - N, len(ref_dirs), axis=0)
1147
+ else:
1148
+ u = np.tile(ref_dirs, (len(N), 1))
1149
+ v = np.repeat(N, len(ref_dirs), axis=0)
1150
+
1151
+ norm_u = np.linalg.norm(u, axis=1)
1152
+
1153
+ scalar_proj = np.sum(v * u, axis=1) / norm_u
1154
+ proj = scalar_proj[:, None] * u / norm_u[:, None]
1155
+ val = np.linalg.norm(proj - v, axis=1)
1156
+ matrix = np.reshape(val, (len(N), len(ref_dirs)))
1157
+
1158
+ return matrix
1159
+
1160
+ def state(self) -> Sequence[Message]:
1161
+ if self.verbosity == 0 or self.selection is None or self.selected_targets is None:
1162
+ return []
1163
+ if self.verbosity == 1:
1164
+ return [
1165
+ Array2DMessage(
1166
+ topic=SelectorMessageTopics.REFERENCE_VECTORS,
1167
+ value=self.reference_vectors.tolist(),
1168
+ source=self.__class__.__name__,
1169
+ ),
1170
+ DictMessage(
1171
+ topic=SelectorMessageTopics.STATE,
1172
+ value={
1173
+ "ideal": self.ideal,
1174
+ "nadir": self.worst_fitness,
1175
+ "extreme_points": self.extreme_points,
1176
+ "n_survive": self.n_survive,
1177
+ },
1178
+ source=self.__class__.__name__,
1179
+ ),
1180
+ ]
1181
+ # verbosity == 2
1182
+ if isinstance(self.selected_individuals, pl.DataFrame):
1183
+ message = PolarsDataFrameMessage(
1184
+ topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
1185
+ value=pl.concat([self.selected_individuals, self.selected_targets], how="horizontal"),
1186
+ source=self.__class__.__name__,
1187
+ )
1188
+ else:
1189
+ warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
1190
+ message = PolarsDataFrameMessage(
1191
+ topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
1192
+ value=self.selected_targets,
1193
+ source=self.__class__.__name__,
1194
+ )
1195
+ state_verbose = [
1196
+ Array2DMessage(
1197
+ topic=SelectorMessageTopics.REFERENCE_VECTORS,
1198
+ value=self.reference_vectors.tolist(),
1199
+ source=self.__class__.__name__,
1200
+ ),
1201
+ DictMessage(
1202
+ topic=SelectorMessageTopics.STATE,
1203
+ value={
1204
+ "ideal": self.ideal,
1205
+ "nadir": self.worst_fitness,
1206
+ "extreme_points": self.extreme_points,
1207
+ "n_survive": self.n_survive,
1208
+ },
1209
+ source=self.__class__.__name__,
1210
+ ),
1211
+ # Array2DMessage(
1212
+ # topic=SelectorMessageTopics.SELECTED_INDIVIDUALS,
1213
+ # value=self.selected_individuals,
1214
+ # source=self.__class__.__name__,
1215
+ # ),
1216
+ message,
1217
+ ]
1218
+ return state_verbose
1219
+
1220
+ def update(self, message: Message) -> None:
1221
+ pass
1222
+
1223
+
1224
+ @njit
1225
+ def _ibea_fitness(fitness_components: np.ndarray, kappa: float) -> np.ndarray:
1226
+ """Calculates the IBEA fitness for each individual based on pairwise fitness components.
1227
+
1228
+ Args:
1229
+ fitness_components (np.ndarray): The pairwise fitness components of the individuals.
1230
+ kappa (float): The kappa value for the IBEA selection.
1231
+
1232
+ Returns:
1233
+ np.ndarray: The IBEA fitness values for each individual.
1234
+ """
1235
+ num_individuals = fitness_components.shape[0]
1236
+ fitness = np.zeros(num_individuals)
1237
+ for i in range(num_individuals):
1238
+ for j in range(num_individuals):
1239
+ if i != j:
1240
+ fitness[i] -= np.exp(-fitness_components[j, i] / kappa)
1241
+ return fitness
1242
+
1243
+
1244
+ @njit
1245
+ def _ibea_select(fitness_components: np.ndarray, bad_sols: np.ndarray, kappa: float) -> int:
1246
+ """Selects the worst individual based on the IBEA indicator.
1247
+
1248
+ Args:
1249
+ fitness_components (np.ndarray): The pairwise fitness components of the individuals.
1250
+ bad_sols (np.ndarray): A boolean array indicating which individuals are considered "bad".
1251
+ kappa (float): The kappa value for the IBEA selection.
1252
+
1253
+ Returns:
1254
+ int: The index of the selected individual.
1255
+ """
1256
+ fitness = np.zeros(len(fitness_components))
1257
+ for i in range(len(fitness_components)):
1258
+ if bad_sols[i]:
1259
+ continue
1260
+ for j in range(len(fitness_components)):
1261
+ if bad_sols[j] or i == j:
1262
+ continue
1263
+ fitness[i] -= np.exp(-fitness_components[j, i] / kappa)
1264
+ choice = np.argmin(fitness)
1265
+ if fitness[choice] >= 0:
1266
+ if sum(bad_sols) == len(fitness_components) - 1:
1267
+ # If all but one individual is chosen, select the last one
1268
+ return np.where(~bad_sols)[0][0]
1269
+ raise RuntimeError("All individuals have non-negative fitness. Cannot select a new individual.")
1270
+ return choice
1271
+
1272
+
1273
+ @njit
1274
+ def _ibea_select_all(fitness_components: np.ndarray, population_size: int, kappa: float) -> np.ndarray:
1275
+ """Selects all individuals based on the IBEA indicator.
1276
+
1277
+ Args:
1278
+ fitness_components (np.ndarray): The pairwise fitness components of the individuals.
1279
+ population_size (int): The desired size of the population after selection.
1280
+ kappa (float): The kappa value for the IBEA selection.
1281
+
1282
+ Returns:
1283
+ list[int]: The list of indices of the selected individuals.
1284
+ """
1285
+ current_pop_size = len(fitness_components)
1286
+ bad_sols = np.zeros(current_pop_size, dtype=np.bool_)
1287
+ fitness = np.zeros(len(fitness_components))
1288
+ mod_fit_components = np.exp(-fitness_components / kappa)
1289
+ for i in range(len(fitness_components)):
1290
+ for j in range(len(fitness_components)):
1291
+ if i == j:
1292
+ continue
1293
+ fitness[i] -= mod_fit_components[j, i]
1294
+ while current_pop_size - sum(bad_sols) > population_size:
1295
+ selected = np.argmin(fitness)
1296
+ if fitness[selected] >= 0:
1297
+ if sum(bad_sols) == len(fitness_components) - 1:
1298
+ # If all but one individual is chosen, select the last one
1299
+ selected = np.where(~bad_sols)[0][0]
1300
+ raise RuntimeError("All individuals have non-negative fitness. Cannot select a new individual.")
1301
+ fitness[selected] = np.inf # Make sure that this individual is not selected again
1302
+ bad_sols[selected] = True
1303
+ for i in range(len(mod_fit_components)):
1304
+ if bad_sols[i]:
1305
+ continue
1306
+ # Update fitness of the remaining individuals
1307
+ fitness[i] += mod_fit_components[selected, i]
1308
+ return ~bad_sols
1309
+
1310
+
1311
+ class IBEASelector(BaseSelector):
1312
+ """The adaptive IBEA selection operator.
1313
+
1314
+ Reference: Zitzler, E., Künzli, S. (2004). Indicator-Based Selection in Multiobjective Search. In: Yao, X., et al.
1315
+ Parallel Problem Solving from Nature - PPSN VIII. PPSN 2004. Lecture Notes in Computer Science, vol 3242.
1316
+ Springer, Berlin, Heidelberg. https://doi.org/10.1007/978-3-540-30217-9_84
1317
+ """
1318
+
1319
+ @property
1320
+ def provided_topics(self):
1321
+ return {
1322
+ 0: [],
1323
+ 1: [SelectorMessageTopics.STATE],
1324
+ 2: [SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS, SelectorMessageTopics.SELECTED_FITNESS],
1325
+ }
1326
+
1327
+ @property
1328
+ def interested_topics(self):
1329
+ return []
1330
+
1331
+ def __init__(
1332
+ self,
1333
+ problem: Problem,
1334
+ verbosity: int,
1335
+ publisher: Publisher,
1336
+ population_size: int,
1337
+ kappa: float = 0.05,
1338
+ binary_indicator: Callable[[np.ndarray], np.ndarray] = self_epsilon,
1339
+ seed: int = 0,
1340
+ ):
1341
+ """Initialize the IBEA selector.
1342
+
1343
+ Args:
1344
+ problem (Problem): The problem to solve.
1345
+ verbosity (int): The verbosity level of the selector.
1346
+ publisher (Publisher): The publisher to send messages to.
1347
+ population_size (int): The size of the population to select.
1348
+ kappa (float, optional): The kappa value for the IBEA selection. Defaults to 0.05.
1349
+ binary_indicator (Callable[[np.ndarray], np.ndarray], optional): The binary indicator function to use.
1350
+ Defaults to self_epsilon with uses binary addaptive epsilon indicator.
1351
+ """
1352
+ # TODO(@light-weaver): IBEA doesn't perform as good as expected
1353
+ # The distribution of solutions found isn't very uniform
1354
+ # Update 21st August, tested against jmetalpy IBEA. Our version is both faster and better
1355
+ # What is happening???
1356
+ # Results are similar to this https://github.com/Xavier-MaYiMing/IBEA/
1357
+ super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)
1358
+ self.selection: list[int] | None = None
1359
+ self.selected_individuals: SolutionType | None = None
1360
+ self.selected_targets: pl.DataFrame | None = None
1361
+ self.binary_indicator = binary_indicator
1362
+ self.kappa = kappa
1363
+ self.population_size = population_size
1364
+ if self.constraints_symbols is not None:
1365
+ raise NotImplementedError("IBEA selector does not support constraints. Please use a different selector.")
1366
+
1367
+ def do(
1368
+ self, parents: tuple[SolutionType, pl.DataFrame], offsprings: tuple[SolutionType, pl.DataFrame]
1369
+ ) -> tuple[SolutionType, pl.DataFrame]:
1370
+ """Perform the selection operation.
1371
+
1372
+ Args:
1373
+ parents (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
1374
+ The second element is the objective values, targets, and constraint violations.
1375
+ offsprings (tuple[SolutionType, pl.DataFrame]): the decision variables as the first element.
1376
+ The second element is the objective values, targets, and constraint violations.
1377
+
1378
+ Returns:
1379
+ tuple[SolutionType, pl.DataFrame]: The selected decision variables and their objective values,
1380
+ targets, and constraint violations.
1381
+ """
1382
+ if self.constraints_symbols is not None:
1383
+ raise NotImplementedError("IBEA selector does not support constraints. Please use a different selector.")
1384
+ if isinstance(parents[0], pl.DataFrame) and isinstance(offsprings[0], pl.DataFrame):
1385
+ solutions = parents[0].vstack(offsprings[0])
1386
+ elif isinstance(parents[0], list) and isinstance(offsprings[0], list):
1387
+ solutions = parents[0] + offsprings[0]
1388
+ else:
1389
+ raise TypeError("The decision variables must be either a list or a polars DataFrame, not both")
1390
+ if len(parents[0]) < self.population_size:
1391
+ return parents[0], parents[1]
1392
+ alltargets = parents[1].vstack(offsprings[1])
1393
+
1394
+ # Adaptation
1395
+ target_vals = alltargets[self.target_symbols].to_numpy()
1396
+ target_min = np.min(target_vals, axis=0)
1397
+ target_max = np.max(target_vals, axis=0)
1398
+ # Scale the targets to the range [0, 1]
1399
+ target_vals = (target_vals - target_min) / (target_max - target_min)
1400
+ fitness_components = self.binary_indicator(target_vals)
1401
+ kappa_mult = np.max(np.abs(fitness_components))
1402
+
1403
+ chosen = _ibea_select_all(
1404
+ fitness_components, population_size=self.population_size, kappa=kappa_mult * self.kappa
1405
+ )
1406
+ self.selected_individuals = solutions.filter(chosen)
1407
+ self.selected_targets = alltargets.filter(chosen)
1408
+ self.selection = chosen
1409
+
1410
+ fitness_components = fitness_components[chosen][:, chosen]
1411
+ self.fitness = _ibea_fitness(fitness_components, kappa=self.kappa * np.abs(fitness_components).max())
1412
+
1413
+ self.notify()
1414
+ return self.selected_individuals, self.selected_targets
1415
+
1416
+ def state(self) -> Sequence[Message]:
1417
+ """Return the state of the selector."""
1418
+ if self.verbosity == 0 or self.selection is None or self.selected_targets is None:
1419
+ return []
1420
+ if self.verbosity == 1:
1421
+ return [
1422
+ DictMessage(
1423
+ topic=SelectorMessageTopics.STATE,
1424
+ value={
1425
+ "population_size": self.population_size,
1426
+ "selected_individuals": self.selection,
1427
+ },
1428
+ source=self.__class__.__name__,
1429
+ )
1430
+ ]
1431
+ # verbosity == 2
1432
+ if isinstance(self.selected_individuals, pl.DataFrame):
1433
+ message = PolarsDataFrameMessage(
1434
+ topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
1435
+ value=pl.concat([self.selected_individuals, self.selected_targets], how="horizontal"),
1436
+ source=self.__class__.__name__,
1437
+ )
1438
+ else:
1439
+ warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
1440
+ message = PolarsDataFrameMessage(
1441
+ topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
1442
+ value=self.selected_targets,
1443
+ source=self.__class__.__name__,
1444
+ )
1445
+ return [
1446
+ DictMessage(
1447
+ topic=SelectorMessageTopics.STATE,
1448
+ value={
1449
+ "population_size": self.population_size,
1450
+ "selected_individuals": self.selection,
1451
+ },
1452
+ source=self.__class__.__name__,
1453
+ ),
1454
+ message,
1455
+ NumpyArrayMessage(
1456
+ topic=SelectorMessageTopics.SELECTED_FITNESS,
1457
+ value=self.fitness,
1458
+ source=self.__class__.__name__,
1459
+ ),
1460
+ ]
1461
+
1462
+ def update(self, message: Message) -> None:
1463
+ pass
1464
+
1465
+
1466
+ @njit
1467
+ def _nsga2_crowding_distance_assignment(
1468
+ non_dominated_front: np.ndarray, f_mins: np.ndarray, f_maxs: np.ndarray
1469
+ ) -> np.ndarray:
1470
+ """Computes the crowding distance as pecified in the definition of NSGA2.
1471
+
1472
+ This function computed the crowding distances for a non-dominated set of solutions.
1473
+ A smaller value means that a solution is more crowded (worse), while a larger value means
1474
+ it is less crowded (better).
1475
+
1476
+ Note:
1477
+ The boundary point in `non_dominated_front` will be assigned a non-crowding
1478
+ distance value of `np.inf` indicating, that they shouls always be included
1479
+ in later sorting.
1480
+
1481
+ Args:
1482
+ non_dominated_front (np.ndarray): a 2D numpy array (size n x m = number
1483
+ of vectors x number of targets (obejctive funcitons)) containing
1484
+ mutually non-dominated vectors. The values of the vectors correspond to
1485
+ the optimization 'target' (usually the minimized objective function
1486
+ values.)
1487
+ f_mins (np.ndarray): a 1D numpy array of size m containing the minimum objective function
1488
+ values in `non_dominated_front`.
1489
+ f_maxs (np.ndarray): a 1D numpy array of size m containing the maximum objective function
1490
+ values in `non_dominated_front`.
1491
+
1492
+ Returns:
1493
+ np.ndarray: a numpy array of size m containing the crowding distances for each vector
1494
+ in `non_dominated_front`.
1495
+
1496
+ Reference: Deb, K., Pratap, A., Agarwal, S., & Meyarivan, T. A. M. T.
1497
+ (2002). A fast and elitist multiobjective genetic algorithm: NSGA-II. IEEE
1498
+ transactions on evolutionary computation, 6(2), 182-197.
1499
+ """
1500
+ vectors = non_dominated_front # I
1501
+ num_vectors = vectors.shape[0] # l
1502
+ num_objectives = vectors.shape[1]
1503
+
1504
+ crowding_distances = np.zeros(num_vectors) # I[i]_distance
1505
+
1506
+ for m in range(num_objectives):
1507
+ # sort by column (objective)
1508
+ m_order = vectors[:, m].argsort()
1509
+ # inlcude boundary points
1510
+ crowding_distances[m_order[0]], crowding_distances[m_order[-1]] = np.inf, np.inf
1511
+
1512
+ for i in range(1, num_vectors - 1):
1513
+ crowding_distances[m_order[i]] = crowding_distances[m_order[i]] + (
1514
+ vectors[m_order[i + 1], m] - vectors[m_order[i - 1], m]
1515
+ ) / (f_maxs[m] - f_mins[m])
1516
+
1517
+ return crowding_distances
1518
+
1519
+
1520
+ class NSGA2Selector(BaseSelector):
1521
+ """Implements the selection operator defined for NSGA2.
1522
+
1523
+ Implements the selection operator defined for NSGA2, which included the crowding
1524
+ distance calculation.
1525
+
1526
+ Reference: Deb, K., Pratap, A., Agarwal, S., & Meyarivan, T. A. M. T.
1527
+ (2002). A fast and elitist multiobjective genetic algorithm: NSGA-II. IEEE
1528
+ transactions on evolutionary computation, 6(2), 182-197.
1529
+ """
1530
+
1531
+ @property
1532
+ def provided_topics(self):
1533
+ """The topics provided for the NSGA2 method."""
1534
+ return {
1535
+ 0: [],
1536
+ 1: [SelectorMessageTopics.STATE],
1537
+ 2: [SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS, SelectorMessageTopics.SELECTED_FITNESS],
1538
+ }
1539
+
1540
+ @property
1541
+ def interested_topics(self):
1542
+ """The topics the NSGA2 method is interested in."""
1543
+ return []
1544
+
1545
+ def __init__(
1546
+ self,
1547
+ problem: Problem,
1548
+ verbosity: int,
1549
+ publisher: Publisher,
1550
+ population_size: int,
1551
+ seed: int = 0,
1552
+ ):
1553
+ super().__init__(problem=problem, verbosity=verbosity, publisher=publisher, seed=seed)
1554
+ if self.constraints_symbols is not None:
1555
+ print(
1556
+ "NSGA2 selector does not currently support constraints. "
1557
+ "Results may vary if used to solve constrainted problems."
1558
+ )
1559
+ self.population_size = population_size
1560
+ self.seed = seed
1561
+ self.selection: list[int] | None = None
1562
+ self.selected_individuals: SolutionType | None = None
1563
+ self.selected_targets: pl.DataFrame | None = None
1564
+
1565
+ def do(
1566
+ self, parents: tuple[SolutionType, pl.DataFrame], offsprings: tuple[SolutionType, pl.DataFrame]
1567
+ ) -> tuple[SolutionType, pl.DataFrame]:
1568
+ """Perform the selection operation."""
1569
+ # First iteration, offspring is empty
1570
+ # Do basic binary tournament selection, recombination, and mutation
1571
+ # In practice, just compute the non-dom ranks and provide them as fitness
1572
+
1573
+ # Off-spring empty (first iteration, compute only non-dominated ranks and provide them as fitness)
1574
+ if offsprings[0].is_empty() and offsprings[1].is_empty():
1575
+ # just compute non-dominated ranks of population and be done
1576
+ parents_a = parents[1][self.target_symbols].to_numpy()
1577
+ fronts = fast_non_dominated_sort(parents_a)
1578
+
1579
+ # assign fitness according to non-dom rank (lower better)
1580
+ scores = np.arange(len(fronts))
1581
+ fitness_values = scores @ fronts
1582
+ self.fitness = fitness_values
1583
+
1584
+ # all selected in first iteration
1585
+ self.selection = list(range(len(parents[1])))
1586
+ self.selected_individuals = parents[0]
1587
+ self.selected_targets = parents[1]
1588
+
1589
+ self.notify()
1590
+
1591
+ return self.selected_individuals, self.selected_targets
1592
+
1593
+ # #Actual selection operator for NSGA2
1594
+
1595
+ # Combine parent and offspring R_t = P_t U Q_t
1596
+ r_solutions = parents[0].vstack(offsprings[0])
1597
+ r_population = parents[1].vstack(offsprings[1])
1598
+ r_targets_arr = r_population[self.target_symbols].to_numpy()
1599
+
1600
+ # the minimum and maximum target values in the whole current population
1601
+ f_mins, f_maxs = np.min(r_targets_arr, axis=0), np.max(r_targets_arr, axis=0)
1602
+
1603
+ # Do fast non-dominated sorting on R_t -> F
1604
+ fronts = fast_non_dominated_sort(r_targets_arr)
1605
+ crowding_distances = np.ones(self.population_size) * np.nan
1606
+ rankings = np.ones(self.population_size) * np.nan
1607
+ fitness_values = np.ones(self.population_size) * np.nan
1608
+
1609
+ # Set the new parent population to P_t+1 = empty and i=1
1610
+ new_parents = np.ones((self.population_size, parents[1].shape[1])) * np.nan
1611
+ new_parents_solutions = np.ones((self.population_size, parents[0].shape[1])) * np.nan
1612
+ parents_ptr = 0 # keep track where stuff was last added
1613
+
1614
+ # the -1 is here because searchsorted returns the index where we can insert the population size to preserve the
1615
+ # order, hence, the previous index of this will be the last element in the cumsum that is less than
1616
+ # the population size
1617
+ last_whole_front_idx = (
1618
+ np.searchsorted(np.cumsum(np.sum(fronts, axis=1)), self.population_size, side="right") - 1
1619
+ )
1620
+
1621
+ last_ranking = 0 # in case first front is larger th population size
1622
+ for i in range(last_whole_front_idx + 1): # inclusive
1623
+ # The looped front here will result in a new population with size <= 100.
1624
+
1625
+ # Compute the crowding distances for F_i
1626
+ distances = _nsga2_crowding_distance_assignment(r_targets_arr[fronts[i]], f_mins, f_maxs)
1627
+ crowding_distances[parents_ptr : parents_ptr + distances.shape[0]] = (
1628
+ distances # distances will have same number of elements as in front[i]
1629
+ )
1630
+
1631
+ # keep track of the rankings as well (best = 0, larger worse). First
1632
+ # non-dom front will have a rank fitness of 0.
1633
+ rankings[parents_ptr : parents_ptr + distances.shape[0]] = i
1634
+
1635
+ # P_t+1 = P_t+1 U F_i
1636
+ new_parents[parents_ptr : parents_ptr + distances.shape[0]] = r_population.filter(fronts[i])
1637
+ new_parents_solutions[parents_ptr : parents_ptr + distances.shape[0]] = r_solutions.filter(fronts[i])
1638
+
1639
+ # compute fitness
1640
+ # infs are checked since boundary points are assigned this value when computing the crowding distance
1641
+ finite_distances = distances[distances != np.inf]
1642
+ max_no_inf = np.nanmax(finite_distances) if finite_distances.size > 0 else np.ones(fronts[i].sum())
1643
+ distances_no_inf = np.nan_to_num(distances, posinf=max_no_inf * 1.1)
1644
+
1645
+ # Distances for the current front normalized between 0 and 1.
1646
+ # The small scalar we add in the nominator and denominator is to
1647
+ # ensure that no distance value would result in exactly 0 after
1648
+ # normalizing, which would increase the corresponding solution
1649
+ # ranking, once reversed, which we do not want to.
1650
+ normalized_distances = (distances_no_inf - (distances_no_inf.min() - 1e-6)) / (
1651
+ distances_no_inf.max() - (distances_no_inf.min() - 1e-6)
1652
+ )
1653
+
1654
+ # since higher is better for the crowded distance, we substract the normalized distances from 1 so that
1655
+ # lower is better, which allows us to combine them with the ranking
1656
+ # No value here should be 1.0 or greater.
1657
+ reversed_distances = 1.0 - normalized_distances
1658
+
1659
+ front_fitness = reversed_distances + rankings[parents_ptr : parents_ptr + distances.shape[0]]
1660
+ fitness_values[parents_ptr : parents_ptr + distances.shape[0]] = front_fitness
1661
+
1662
+ # increment parent pointer
1663
+ parents_ptr += distances.shape[0]
1664
+
1665
+ # keep track of last given rank
1666
+ last_ranking = i
1667
+
1668
+ # deal with last (partial) front, if needed
1669
+ trimmed_and_sorted_indices = None
1670
+ if parents_ptr < self.population_size:
1671
+ distances = _nsga2_crowding_distance_assignment(
1672
+ r_targets_arr[fronts[last_whole_front_idx + 1]], f_mins, f_maxs
1673
+ )
1674
+
1675
+ # Sort F_i in descending order according to crowding distance
1676
+ # This makes picking the selected part of the partial front easier
1677
+ trimmed_and_sorted_indices = distances.argsort()[::-1][: self.population_size - parents_ptr]
1678
+
1679
+ crowding_distances[parents_ptr : self.population_size] = distances[trimmed_and_sorted_indices]
1680
+ rankings[parents_ptr : self.population_size] = last_ranking + 1
1681
+
1682
+ # P_t+1 = P_t+1 U F_i[1: (N - |P_t+1|)]
1683
+ new_parents[parents_ptr : self.population_size] = r_population.filter(fronts[last_whole_front_idx + 1])[
1684
+ trimmed_and_sorted_indices
1685
+ ]
1686
+ new_parents_solutions[parents_ptr : self.population_size] = r_solutions.filter(
1687
+ fronts[last_whole_front_idx + 1]
1688
+ )[trimmed_and_sorted_indices]
1689
+
1690
+ # compute fitness (see above for details)
1691
+ finite_distances = distances[trimmed_and_sorted_indices][distances[trimmed_and_sorted_indices] != np.inf]
1692
+ max_no_inf = (
1693
+ np.nanmax(finite_distances)
1694
+ if finite_distances.size > 0
1695
+ else np.ones(len(trimmed_and_sorted_indices)) # we have only boundary points
1696
+ )
1697
+ distances_no_inf = np.nan_to_num(distances[trimmed_and_sorted_indices], posinf=max_no_inf * 1.1)
1698
+
1699
+ normalized_distances = (distances_no_inf - (distances_no_inf.min() - 1e-6)) / (
1700
+ distances_no_inf.max() - (distances_no_inf.min() - 1e-6)
1701
+ )
1702
+
1703
+ reversed_distances = 1.0 - normalized_distances
1704
+
1705
+ front_fitness = reversed_distances + rankings[parents_ptr : self.population_size]
1706
+ fitness_values[parents_ptr : parents_ptr + self.population_size] = front_fitness
1707
+
1708
+ # back to polars, return values
1709
+ solutions = pl.DataFrame(new_parents_solutions, schema=parents[0].schema)
1710
+ outputs = pl.DataFrame(new_parents, schema=parents[1].schema)
1711
+
1712
+ self.fitness = fitness_values
1713
+
1714
+ whole_fronts = fronts[: last_whole_front_idx + 1]
1715
+ whole_indices = [np.where(row)[0].tolist() for row in whole_fronts]
1716
+
1717
+ if trimmed_and_sorted_indices is not None:
1718
+ # partial front considered
1719
+ partial_front = fronts[last_whole_front_idx + 1]
1720
+ partial_indices = np.where(partial_front)[0][trimmed_and_sorted_indices].tolist()
1721
+ else:
1722
+ partial_indices = []
1723
+
1724
+ self.selection = [index for indices in whole_indices for index in indices] + partial_indices
1725
+ self.selected_individuals = solutions
1726
+ self.selected_targets = outputs
1727
+
1728
+ self.notify()
1729
+ return solutions, outputs
1730
+
1731
+ def state(self) -> Sequence[Message]:
1732
+ """Return the state of the selector."""
1733
+ if self.verbosity == 0 or self.selection is None or self.selected_targets is None:
1734
+ return []
1735
+ if self.verbosity == 1:
1736
+ return [
1737
+ DictMessage(
1738
+ topic=SelectorMessageTopics.STATE,
1739
+ value={
1740
+ "population_size": self.population_size,
1741
+ "selected_individuals": self.selection,
1742
+ },
1743
+ source=self.__class__.__name__,
1744
+ )
1745
+ ]
1746
+ # verbosity == 2
1747
+ if isinstance(self.selected_individuals, pl.DataFrame):
1748
+ message = PolarsDataFrameMessage(
1749
+ topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
1750
+ value=pl.concat([self.selected_individuals, self.selected_targets], how="horizontal"),
1751
+ source=self.__class__.__name__,
1752
+ )
1753
+ else:
1754
+ warnings.warn("Population is not a Polars DataFrame. Defaulting to providing OUTPUTS only.", stacklevel=2)
1755
+ message = PolarsDataFrameMessage(
1756
+ topic=SelectorMessageTopics.SELECTED_VERBOSE_OUTPUTS,
1757
+ value=self.selected_targets,
1758
+ source=self.__class__.__name__,
1759
+ )
1760
+ return [
1761
+ DictMessage(
1762
+ topic=SelectorMessageTopics.STATE,
1763
+ value={
1764
+ "population_size": self.population_size,
1765
+ "selected_individuals": self.selection,
1766
+ },
1767
+ source=self.__class__.__name__,
1768
+ ),
1769
+ message,
1770
+ NumpyArrayMessage(
1771
+ topic=SelectorMessageTopics.SELECTED_FITNESS,
1772
+ value=self.fitness,
1773
+ source=self.__class__.__name__,
1774
+ ),
1775
+ ]
1776
+
1777
+ def update(self, message: Message) -> None:
1778
+ pass