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
desdeo/mcdm/nimbus.py ADDED
@@ -0,0 +1,417 @@
1
+ """Functions related to the Sychronous NIMBUS method.
2
+
3
+ References:
4
+ Miettinen, K., & Mäkelä, M. M. (2006). Synchronous approach in interactive
5
+ multiobjective optimization. European Journal of Operational Research,
6
+ 170(3), 909–922.
7
+ """ # noqa: RUF002
8
+
9
+ import numpy as np
10
+ import polars as pl
11
+
12
+ from desdeo.problem import (
13
+ PolarsEvaluator,
14
+ Problem,
15
+ Variable,
16
+ VariableType,
17
+ flatten_variable_dict,
18
+ unflatten_variable_array,
19
+ )
20
+ from desdeo.tools import (
21
+ BaseSolver,
22
+ SolverOptions,
23
+ SolverResults,
24
+ add_asf_diff,
25
+ add_asf_nondiff,
26
+ add_guess_sf_diff,
27
+ add_guess_sf_nondiff,
28
+ add_nimbus_sf_diff,
29
+ add_nimbus_sf_nondiff,
30
+ add_stom_sf_diff,
31
+ add_stom_sf_nondiff,
32
+ guess_best_solver,
33
+ )
34
+
35
+
36
+ class NimbusError(Exception):
37
+ """Raised when an error with a NIMBUS method is encountered."""
38
+
39
+
40
+ def solve_intermediate_solutions( # noqa: PLR0913
41
+ problem: Problem,
42
+ solution_1: dict[str, VariableType],
43
+ solution_2: dict[str, VariableType],
44
+ num_desired: int,
45
+ scalarization_options: dict | None = None,
46
+ solver: BaseSolver | None = None,
47
+ solver_options: SolverOptions | None = None,
48
+ ) -> list[SolverResults]:
49
+ """Generates a desired number of intermediate solutions between two given solutions.
50
+
51
+ Generates a desires number of intermediate solutions given two Pareto optimal solutions.
52
+ The solutions are generated by taking n number of steps between the two solutions in the
53
+ objective space. The objective vectors corresponding to these solutions are then
54
+ utilized as reference points in the achievement scalarizing function. Solving the functions
55
+ for each reference point will project the reference point on the Pareto optimal
56
+ front of the problem. These projected solutions are then returned. Note that the
57
+ intermediate solutions are generated _between_ the two given solutions, this means the
58
+ returned solutions will not include the original points.
59
+
60
+ Args:
61
+ problem (Problem): the problem being solved.
62
+ solution_1 (dict[str, VariableType]): the first of the solutions between which the intermediate
63
+ solutions are to be generated.
64
+ solution_2 (dict[str, VariableType]): the second of the solutions between which the intermediate
65
+ solutions are to be generated.
66
+ num_desired (int): the number of desired intermediate solutions to be generated. Must be at least `1`.
67
+ scalarization_options (dict | None, optional): optional kwargs passed to the scalarization function.
68
+ Defaults to None.
69
+ solver (BaseSolver | None, optional): solver used to solve the problem.
70
+ If not given, an appropriate solver will be automatically determined based on the features of `problem`.
71
+ Defaults to None.
72
+ solver_options (SolverOptions | None, optional): optional options passed
73
+ to the `solver`. Ignored if `solver` is `None`.
74
+ Defaults to None.
75
+
76
+ Returns:
77
+ list[SolverResults]: a list with the projected intermediate solutions as
78
+ `SolverResults` objects.
79
+ """
80
+ if int(num_desired) < 1:
81
+ msg = f"The given number of desired intermediate ({num_desired=}) solutions must be at least 1."
82
+ raise NimbusError(msg)
83
+
84
+ init_solver = guess_best_solver(problem) if solver is None else solver
85
+ _solver_options = None if solver_options is None or solver is None else solver_options
86
+
87
+ # compute the element-wise difference between each solution (in the decision space)
88
+ solution_1_arr = flatten_variable_dict(problem, solution_1)
89
+ solution_2_arr = flatten_variable_dict(problem, solution_2)
90
+ delta = solution_1_arr - solution_2_arr
91
+
92
+ # the '2' is in the denominator because we want to calculate the steps
93
+ # between the two given points; we are not interested in the given points themselves.
94
+ step_size = delta / (2 + num_desired)
95
+
96
+ intermediate_points = np.array([solution_2_arr + i * step_size for i in range(1, num_desired + 1)])
97
+
98
+ intermediate_var_values = pl.DataFrame(
99
+ [unflatten_variable_array(problem, x) for x in intermediate_points],
100
+ schema=[
101
+ (var.symbol, pl.Float64 if isinstance(var, Variable) else pl.Array(pl.Float64, tuple(var.shape)))
102
+ for var in problem.variables
103
+ ],
104
+ )
105
+
106
+ # evaluate the intermediate points to get reference points
107
+ # TODO(gialmisi): an evaluator might have to be selected depending on the problem
108
+ evaluator = PolarsEvaluator(problem)
109
+
110
+ reference_points = (
111
+ evaluator.evaluate(intermediate_var_values).select([obj.symbol for obj in problem.objectives]).to_dicts()
112
+ )
113
+
114
+ # for each reference point, add and solve the ASF scalarization problem
115
+ # projecting the reference point onto the Pareto optimal front of the problem.
116
+ # TODO(gialmisi): this can be done in parallel.
117
+ intermediate_solutions = []
118
+ for rp in reference_points:
119
+ # add scalarization
120
+ add_asf = add_asf_diff if problem.is_twice_differentiable else add_asf_nondiff
121
+ asf_problem, target = add_asf(problem, "target", rp, **(scalarization_options or {}))
122
+
123
+ solver = init_solver(asf_problem, _solver_options)
124
+
125
+ # solve and store results
126
+ result: SolverResults = solver.solve(target)
127
+
128
+ intermediate_solutions.append(result)
129
+
130
+ return intermediate_solutions
131
+
132
+
133
+ def infer_classifications(
134
+ problem: Problem, current_objectives: dict[str, float], reference_point: dict[str, float]
135
+ ) -> dict[str, tuple[str, float | None]]:
136
+ r"""Infers NIMBUS classifications based on a reference point and current objective values.
137
+
138
+ Infers the classifications based on a given reference point and current objective function
139
+ values. The following classifications are inferred for each objective:
140
+
141
+ - $I^{<}$: values that should improve, the reference point value of an objective
142
+ function is equal to its ideal value;
143
+ - $I^{\leq}$: values that should improve until a given aspiration level, the reference point
144
+ value of an objective function is better than the current value;
145
+ - $I^{=}$: values that should stay as they are, the reference point value of an objective
146
+ function is equal to the current value;
147
+ - $I^{\geq}$: values that can be impaired until some reservation level, the reference point
148
+ value of an objective function is worse than the current value; and
149
+ - $I^{\diamond}$: values that are allowed to change freely, the reference point value of
150
+ and objective function is equal to its nadir value.
151
+
152
+ The aspiration levels and the reservation levels are then given for each classification, when relevant, in
153
+ the return value of this function as the following example demonstrates:
154
+
155
+ ```python
156
+ classifications = {
157
+ "f_1": ("<", None),
158
+ "f_2": ("<=", 42.1),
159
+ "f_3": (">=", 22.2),
160
+ "f_4": ("0", None)
161
+ }
162
+ ```
163
+
164
+ Raises:
165
+ NimbusError: the ideal or nadir point, or both, of the given
166
+ problem are undefined.
167
+ NimbusError: the reference point or current objectives are missing
168
+ entries for one or more of the objective functions defined in
169
+ the problem.
170
+
171
+ Args:
172
+ problem (Problem): the problem the current objectives and the reference point
173
+ are related to.
174
+ current_objectives (dict[str, float]): an objective dictionary with the current
175
+ objective functions values.
176
+ reference_point (dict[str, float]): an objective dictionary with the reference point
177
+ values.
178
+
179
+ Returns:
180
+ dict[str, tuple[str, float | None]]: a dict with keys corresponding to the
181
+ symbols of the objective functions defined for the problem and with values
182
+ of tuples, where the first element is the classification (str) and the second
183
+ element is either a reservation level (in case of classification `>=`) or an
184
+ aspiration level (in case of classification `<=`).
185
+ """
186
+ if None in problem.get_ideal_point() or None in problem.get_nadir_point():
187
+ msg = "The given problem must have both an ideal and nadir point defined."
188
+ raise NimbusError(msg)
189
+
190
+ if not all(obj.symbol in reference_point for obj in problem.objectives):
191
+ msg = f"The reference point {reference_point} is missing entries " "for one or more of the objective functions."
192
+ raise NimbusError(msg)
193
+
194
+ if not all(obj.symbol in current_objectives for obj in problem.objectives):
195
+ msg = (
196
+ f"The current point {current_objectives} is missing entries " "for one or more of the objective functions."
197
+ )
198
+ raise NimbusError(msg)
199
+
200
+ # derive the classifications based on the reference point and and previous
201
+ # objective function values
202
+ classifications = {}
203
+
204
+ for obj in problem.objectives:
205
+ if np.isclose(reference_point[obj.symbol], obj.nadir):
206
+ # the objective is free to change
207
+ classification = {obj.symbol: ("0", None)}
208
+ elif np.isclose(reference_point[obj.symbol], obj.ideal):
209
+ # the objective should improve
210
+ classification = {obj.symbol: ("<", None)}
211
+ elif np.isclose(reference_point[obj.symbol], current_objectives[obj.symbol]):
212
+ # the objective should stay as it is
213
+ classification = {obj.symbol: ("=", None)}
214
+ elif not obj.maximize and reference_point[obj.symbol] < current_objectives[obj.symbol]:
215
+ # minimizing objective, reference value smaller, this is an aspiration level
216
+ # improve until
217
+ classification = {obj.symbol: ("<=", reference_point[obj.symbol])}
218
+ elif not obj.maximize and reference_point[obj.symbol] > current_objectives[obj.symbol]:
219
+ # minimizing objective, reference value is greater, this is a reservations level
220
+ # impair until
221
+ classification = {obj.symbol: (">=", reference_point[obj.symbol])}
222
+ elif obj.maximize and reference_point[obj.symbol] < current_objectives[obj.symbol]:
223
+ # maximizing objective, reference value is smaller, this is a reservation level
224
+ # impair until
225
+ classification = {obj.symbol: (">=", reference_point[obj.symbol])}
226
+ elif obj.maximize and reference_point[obj.symbol] > current_objectives[obj.symbol]:
227
+ # maximizing objective, reference value is greater, this is an aspiration level
228
+ # improve until
229
+ classification = {obj.symbol: ("<=", reference_point[obj.symbol])}
230
+ else:
231
+ # could not figure classification
232
+ msg = f"Warning: NIMBUS could not figure out the classification for objective {obj.symbol}."
233
+
234
+ classifications |= classification
235
+
236
+ return classifications
237
+
238
+
239
+ def solve_sub_problems( # noqa: PLR0913
240
+ problem: Problem,
241
+ current_objectives: dict[str, float],
242
+ reference_point: dict[str, float],
243
+ num_desired: int,
244
+ scalarization_options: dict | None = None,
245
+ solver: BaseSolver | None = None,
246
+ solver_options: SolverOptions | None = None,
247
+ ) -> list[SolverResults]:
248
+ r"""Solves a desired number of sub-problems as defined in the NIMBUS methods.
249
+
250
+ Solves 1-4 scalarized problems utilizing different scalarization
251
+ functions. The scalarizations are based on the classification of a
252
+ solutions provided by a decision maker. The classifications
253
+ are represented by a reference point. Returns a number of new solutions
254
+ corresponding to the number of scalarization functions solved.
255
+
256
+ Depending on `num_desired`, solves the following scalarized problems corresponding
257
+ the the following scalarization functions:
258
+
259
+ 1. the NIMBUS scalarization function,
260
+ 2. the STOM scalarization function,
261
+ 3. the achievement scalarizing function, and
262
+ 4. the GUESS scalarization function.
263
+
264
+ Raises:
265
+ NimbusError: the given problem has an undefined ideal or nadir point, or both.
266
+ NimbusError: either the reference point of current objective functions value are
267
+ missing entries for one or more of the objective functions defined in the problem.
268
+
269
+ Args:
270
+ problem (Problem): the problem being solved.
271
+ current_objectives (dict[str, float]): an objective dictionary with the objective functions values
272
+ the classifications have been given with respect to.
273
+ reference_point (dict[str, float]): an objective dictionary with a reference point.
274
+ The classifications utilized in the sub problems are derived from
275
+ the reference point.
276
+ num_desired (int): the number of desired solutions to be solved. Solves as
277
+ many scalarized problems. The value must be in the range 1-4.
278
+ scalarization_options (dict | None, optional): optional kwargs passed to the scalarization function.
279
+ Defaults to None.
280
+ solver (BaseSolver | None, optional): solver used to solve the problem.
281
+ If not given, an appropriate solver will be automatically determined based on the features of `problem`.
282
+ Defaults to None.
283
+ solver_options (SolverOptions | None, optional): optional options passed
284
+ to the `solver`. Ignored if `solver` is `None`.
285
+ Defaults to None.
286
+
287
+ Returns:
288
+ list[SolverResults]: a list of `SolverResults` objects. Contains as many elements
289
+ as defined in `num_desired`.
290
+ """
291
+ if None in problem.get_ideal_point() or None in problem.get_nadir_point():
292
+ msg = "The given problem must have both an ideal and nadir point defined."
293
+ raise NimbusError(msg)
294
+
295
+ if not all(obj.symbol in reference_point for obj in problem.objectives):
296
+ msg = f"The reference point {reference_point} is missing entries " "for one or more of the objective functions."
297
+ raise NimbusError(msg)
298
+
299
+ if not all(obj.symbol in current_objectives for obj in problem.objectives):
300
+ msg = f"The current point {reference_point} is missing entries " "for one or more of the objective functions."
301
+ raise NimbusError(msg)
302
+
303
+ init_solver = solver if solver is not None else guess_best_solver(problem)
304
+ _solver_options = solver_options if solver_options is not None else None
305
+
306
+ # derive the classifications based on the reference point and and previous
307
+ # objective function values
308
+ classifications = infer_classifications(problem, current_objectives, reference_point)
309
+
310
+ solutions = []
311
+
312
+ # solve the nimbus scalarization problem, this is done always
313
+ add_nimbus_sf = add_nimbus_sf_diff if problem.is_twice_differentiable else add_nimbus_sf_nondiff
314
+
315
+ problem_w_nimbus, nimbus_target = add_nimbus_sf(
316
+ problem, "nimbus_sf", classifications, current_objectives, **(scalarization_options or {})
317
+ )
318
+
319
+ nimbus_solver = init_solver(problem_w_nimbus, _solver_options) if _solver_options else init_solver(problem_w_nimbus)
320
+
321
+ solutions.append(nimbus_solver.solve(nimbus_target))
322
+
323
+ if num_desired > 1:
324
+ # solve STOM
325
+ add_stom_sf = add_stom_sf_diff if problem.is_twice_differentiable else add_stom_sf_nondiff
326
+
327
+ problem_w_stom, stom_target = add_stom_sf(problem, "stom_sf", reference_point, **(scalarization_options or {}))
328
+ stom_solver = init_solver(problem_w_stom, _solver_options) if _solver_options else init_solver(problem_w_stom)
329
+
330
+ solutions.append(stom_solver.solve(stom_target))
331
+
332
+ if num_desired > 2: # noqa: PLR2004
333
+ # solve ASF
334
+ add_asf = add_asf_diff if problem.is_twice_differentiable else add_asf_nondiff
335
+
336
+ problem_w_asf, asf_target = add_asf(problem, "asf", reference_point, **(scalarization_options or {}))
337
+
338
+ asf_solver = init_solver(problem_w_asf, _solver_options) if _solver_options else init_solver(problem_w_asf)
339
+
340
+ solutions.append(asf_solver.solve(asf_target))
341
+
342
+ if num_desired > 3: # noqa: PLR2004
343
+ # solve GUESS
344
+ add_guess_sf = add_guess_sf_diff if problem.is_twice_differentiable else add_guess_sf_nondiff
345
+
346
+ problem_w_guess, guess_target = add_guess_sf(
347
+ problem, "guess_sf", reference_point, **(scalarization_options or {})
348
+ )
349
+
350
+ if _solver_options:
351
+ guess_solver = init_solver(problem_w_guess, _solver_options)
352
+ else:
353
+ guess_solver = init_solver(problem_w_guess)
354
+
355
+ solutions.append(guess_solver.solve(guess_target))
356
+
357
+ return solutions
358
+
359
+
360
+ def generate_starting_point(
361
+ problem: Problem,
362
+ reference_point: dict[str, float] | None = None,
363
+ scalarization_options: dict | None = None,
364
+ solver: BaseSolver | None = None,
365
+ solver_options: SolverOptions | None = None,
366
+ ) -> SolverResults:
367
+ r"""Generates a starting point for the NIMBUS method.
368
+
369
+ Using the given reference point and achievement scalarizing function, finds one pareto
370
+ optimal solution that can be used as a starting point for the NIMBUS method.
371
+ If no reference point is given, ideal is used as the reference point.
372
+
373
+ Instead of using this function, the user can provide a starting point.
374
+
375
+ Raises:
376
+ NimbusError: the given problem has an undefined ideal or nadir point, or both.
377
+
378
+ Args:
379
+ problem (Problem): the problem being solved.
380
+ reference_point (dict[str, float]|None): an objective dictionary with a reference point.
381
+ If not given, ideal will be used as reference point.
382
+ scalarization_options (dict | None, optional): optional kwargs passed to the scalarization function.
383
+ Defaults to None.
384
+ solver (BaseSolver | None, optional): solver used to solve the problem.
385
+ If not given, an appropriate solver will be automatically determined based on the features of `problem`.
386
+ Defaults to None.
387
+ solver_options (SolverOptions | None, optional): optional options passed
388
+ to the `solver`. Ignored if `solver` is `None`.
389
+ Defaults to None.
390
+
391
+ Returns:
392
+ list[SolverResults]: a list of `SolverResults` objects. Contains as many elements
393
+ as defined in `num_desired`.
394
+ """
395
+ ideal = problem.get_ideal_point()
396
+ nadir = problem.get_nadir_point()
397
+ if None in ideal or None in nadir:
398
+ msg = "The given problem must have both an ideal and nadir point defined."
399
+ raise NimbusError(msg)
400
+
401
+ if reference_point is None:
402
+ reference_point = {}
403
+ for obj in problem.objectives:
404
+ if obj.symbol not in reference_point:
405
+ reference_point[obj.symbol] = ideal[obj.symbol]
406
+
407
+ init_solver = solver if solver is not None else guess_best_solver(problem)
408
+ _solver_options = solver_options if solver_options is not None else None
409
+
410
+ # solve ASF
411
+ add_asf = add_asf_diff if problem.is_twice_differentiable else add_asf_nondiff
412
+
413
+ problem_w_asf, asf_target = add_asf(problem, "asf", reference_point, **(scalarization_options or {}))
414
+
415
+ asf_solver = init_solver(problem_w_asf, _solver_options) if _solver_options else init_solver(problem_w_asf)
416
+
417
+ return asf_solver.solve(asf_target)
@@ -0,0 +1,269 @@
1
+ """Functions related to the Pareto Navigator method are defined here.
2
+
3
+ Reference of the method:
4
+
5
+ Eskelinen, Petri, et al. "Pareto navigator for interactive nonlinear
6
+ multiobjective optimization." OR spectrum 32 (2010): 211-227.
7
+ """
8
+
9
+ import numpy as np
10
+ from scipy.optimize import linprog
11
+ from scipy.spatial import ConvexHull
12
+
13
+ from desdeo.problem import (
14
+ Problem,
15
+ numpy_array_to_objective_dict,
16
+ objective_dict_to_numpy_array
17
+ )
18
+
19
+
20
+ def classification_to_reference_point(
21
+ problem: Problem,
22
+ pref_info: dict[str, str],
23
+ current_solution: dict[str, float]
24
+ ) -> dict[str, float]:
25
+ """Convert preference information given as classification into a reference point.
26
+
27
+ Args:
28
+ problem (Problem): The problem being solved.
29
+ pref_info (dict[str, str]): The preference information given as classification.
30
+ current_solution (dict[str, float]): The current solution.
31
+
32
+ Returns:
33
+ dict[str, float]: A reference point converted from classification.
34
+ """
35
+ ref = []
36
+ ideal = problem.get_ideal_point()
37
+ nadir = problem.get_nadir_point()
38
+
39
+ for pref in pref_info:
40
+ if pref_info[pref] == "<":
41
+ ref.append(ideal[pref])
42
+ elif pref_info[pref] == ">":
43
+ ref.append(nadir[pref])
44
+ elif pref_info[pref] == "=":
45
+ ref.append(current_solution[pref])
46
+
47
+ return numpy_array_to_objective_dict(problem, np.array(ref))
48
+
49
+ def calculate_adjusted_speed(allowed_speeds: list[int], speed: float, scalar: float | None = 20) -> float:
50
+ """Calculate an adjusted speed from a given float.
51
+
52
+ Note:
53
+ Adjusting the speed is not specified in the article but seems necessary.
54
+
55
+ Args:
56
+ allowed_speeds (np.ndarray): An array of allowed speeds.
57
+ speed (float): A given speed value where.
58
+ scalar (float | None (optional)): A scale to adjust the speed. Defaults to 20.
59
+
60
+ Returns:
61
+ float: An adjusted speed value calculated from given float.
62
+ Is between 0 and 1.
63
+ """
64
+ return (speed / np.max(allowed_speeds)) / scalar
65
+
66
+ def calculate_search_direction(
67
+ problem: Problem,
68
+ reference_point: dict[str, float],
69
+ current_point: dict[str, float]
70
+ ) -> dict[str, float]:
71
+ """Calculate search direction from the current point to the reference point.
72
+
73
+ Args:
74
+ problem (Problem): The problem being solved.
75
+ reference_point (dict[str, float]): The given reference point.
76
+ current_point (dict[str, float]): Currently navigated point.
77
+
78
+ Returns:
79
+ dict[str, float]: The direction from the current point to the reference point.
80
+ """
81
+ z = objective_dict_to_numpy_array(problem, current_point)
82
+ q = objective_dict_to_numpy_array(problem, reference_point)
83
+
84
+ d = q - z
85
+ return numpy_array_to_objective_dict(problem, d)
86
+
87
+ def get_polyhedral_set(problem: Problem) -> tuple[np.ndarray, np.ndarray]:
88
+ """Get a polyhedral set as convex hull from the set of pareto optimal solutions.
89
+
90
+ Args:
91
+ problem (Problem): The problem being solved.
92
+
93
+ Returns:
94
+ tuple[np.ndarray, np.ndarray]: The A matrix and b vector from the polyhedral set equation.
95
+ """
96
+ objective_values = problem.discrete_representation.objective_values
97
+ representation = np.array([objective_values[obj.symbol] for obj in problem.objectives])
98
+
99
+ convex_hull = ConvexHull(representation.T)
100
+ matrix_a = convex_hull.equations[:, 0:-1]
101
+ b = -convex_hull.equations[:, -1]
102
+ return matrix_a, b
103
+
104
+ def construct_matrix_a(problem: Problem, matrix_a: np.ndarray) -> np.ndarray:
105
+ """Construct the A' matrix in the linear parametric programming problem from the article.
106
+
107
+ Args:
108
+ problem (Problem): The problem being solved.
109
+ matrix_a (np.ndarray): The A matrix from the polyhedral set equation.
110
+
111
+ Returns:
112
+ np.ndarray: The A' matrix in the linear parametric programming problem from the article.
113
+ """
114
+ ideal = objective_dict_to_numpy_array(problem, problem.get_ideal_point())
115
+ nadir = objective_dict_to_numpy_array(problem, problem.get_nadir_point())
116
+ weights = 1/(nadir - ideal)
117
+
118
+ weights_inverse = np.reshape(np.vectorize(lambda w: -1 / w)(weights), (len(weights), 1))
119
+ identity = np.identity(len(weights))
120
+ a_upper = np.c_[weights_inverse, identity]
121
+
122
+ zeros = np.zeros((len(matrix_a), 1))
123
+ a_lower = np.c_[zeros, matrix_a]
124
+
125
+ return np.concatenate((a_upper, a_lower))
126
+
127
+ def calculate_next_solution( # NOQA: PLR0913
128
+ problem: Problem,
129
+ search_direction: dict[str, float],
130
+ current_solution: dict[str, float],
131
+ alpha: float,
132
+ matrix_a: np.ndarray,
133
+ b: np.ndarray
134
+ ) -> dict[str, float]:
135
+ """Calculate the next solution.
136
+
137
+ Args:
138
+ problem (Problem): The problem being solved.
139
+ search_direction (dict[str, float]): The search direction.
140
+ current_solution (dict[str, float]): The currently navigated point.
141
+ alpha (float): Step size. Between 0 and 1.
142
+ matrix_a (np.ndarray): The A' matrix.
143
+ b (np.ndarray): The b vector.
144
+
145
+ Returns:
146
+ dict[str, float]: The next solution.
147
+ """
148
+ z = objective_dict_to_numpy_array(problem, current_solution)
149
+ k = len(z)
150
+ d = objective_dict_to_numpy_array(problem, search_direction)
151
+
152
+ q = z + alpha * d
153
+ q = np.reshape(q, ((k, 1)))
154
+
155
+ b_new = np.append(q, b)
156
+
157
+ ideal = objective_dict_to_numpy_array(problem, problem.get_ideal_point())
158
+ nadir = objective_dict_to_numpy_array(problem, problem.get_nadir_point())
159
+
160
+ c = np.array([1] + k * [0])
161
+
162
+ obj_bounds = np.stack((ideal, nadir))
163
+ bounds = [(None, None)]
164
+ for x, y in obj_bounds.T:
165
+ bounds.append((x, y))
166
+
167
+ z_new = linprog(c=c, A_ub=matrix_a, b_ub=b_new, bounds=bounds)
168
+ if z_new["success"]:
169
+ return numpy_array_to_objective_dict(problem, z_new["x"][1:])
170
+ return current_solution # should raise an exception instead
171
+
172
+ def calculate_all_solutions(
173
+ problem: Problem,
174
+ current_solution: dict[str, float],
175
+ alpha: float,
176
+ num_solutions: int,
177
+ pref_info: dict
178
+ ) -> list[dict[str, float]]:
179
+ """Performs a set number of steps in the current direction.
180
+
181
+ Args:
182
+ problem (Problem): The problem being solved.
183
+ current_solution (dict[str, float]): The current solution.
184
+ alpha (float): Step size. Between 0 and 1.
185
+ num_solutions (int): Number of solutions calculated.
186
+ pref_info (dict): Preference information. Either "reference_point" or "classification".
187
+
188
+ Returns:
189
+ list[dict[str, float]]: A list of the computed solutions.
190
+ """
191
+ solution = current_solution
192
+
193
+ # check if the preference information is given as a reference point or as classification
194
+ # and calculate the search direction based on the preference information
195
+ if "reference_point" in pref_info:
196
+ reference_point = pref_info["reference_point"]
197
+ d = calculate_search_direction(problem, reference_point, current_solution)
198
+ elif "classification" in pref_info:
199
+ reference_point = classification_to_reference_point(problem, pref_info["classification"], solution)
200
+ d = calculate_search_direction(problem, reference_point, solution)
201
+
202
+ # the A matrix and b vector from the polyhedral set equation
203
+ matrix_a, b = get_polyhedral_set(problem)
204
+
205
+ # the A' matrix from the linear parametric programming problem
206
+ matrix_a_new = construct_matrix_a(problem, matrix_a)
207
+
208
+ solutions: list[dict[str, float]] = []
209
+ while len(solutions) < num_solutions:
210
+ solution = calculate_next_solution(problem, d, solution, alpha, matrix_a_new, b)
211
+ solutions.append(solution)
212
+ return solutions
213
+
214
+ # Testing
215
+ if __name__ == "__main__":
216
+ from desdeo.problem import pareto_navigator_test_problem
217
+
218
+ problem = pareto_navigator_test_problem()
219
+ ideal = problem.get_ideal_point()
220
+ nadir = problem.get_nadir_point()
221
+ speed = 1
222
+ allowed_speeds = [1, 2, 3, 4, 5]
223
+ adjusted_speed = calculate_adjusted_speed(allowed_speeds, speed)
224
+
225
+ starting_point = {"f_1": 1.38, "f_2": 0.62, "f_3": -35.33}
226
+
227
+ preference_info = {
228
+ #"reference_point": {"f_1": ideal["f_1"], "f_2": ideal["f_2"], "f_3": nadir["f_3"]}
229
+ "classification": {"f_1": "<", "f_2": "<", "f_3": ">"}
230
+ }
231
+
232
+ num_solutions = 200
233
+ acc = 0.15
234
+ solutions = calculate_all_solutions(problem, starting_point, adjusted_speed, num_solutions, preference_info)
235
+ navigated_point = starting_point
236
+
237
+ for i in range(len(solutions)):
238
+ if np.all(np.abs(objective_dict_to_numpy_array(problem, solutions[i])
239
+ - np.array([0.35, -0.51, -26.26])) < acc):
240
+ print("Values close enough to the ones in the article reached. ", solutions[i])
241
+ navigated_point = solutions[i]
242
+ break
243
+
244
+ preference_info = {
245
+ #"reference_point": {"f_1": ideal["f_1"], "f_2": nadir["f_2"], "f_3": navigated_point["f_3"]}
246
+ "classification": {"f_1": "<", "f_2": ">", "f_3": "="}
247
+ }
248
+
249
+ solutions = calculate_all_solutions(problem, navigated_point, adjusted_speed, num_solutions, preference_info)
250
+
251
+ for i in range(len(solutions)):
252
+ if np.all(np.abs(objective_dict_to_numpy_array(problem, solutions[i])
253
+ - np.array([-0.89, 2.91, -24.98])) < acc):
254
+ print("Values close enough to the ones in the article reached. ", solutions[i])
255
+ navigated_point = solutions[i]
256
+ break
257
+
258
+ preference_info = {
259
+ #"reference_point": {"f_1": nadir["f_1"], "f_2": ideal["f_2"], "f_3": ideal["f_3"]}
260
+ "classification": {"f_1": ">", "f_2": "<", "f_3": "<"}
261
+ }
262
+ solutions = calculate_all_solutions(problem, navigated_point, adjusted_speed, num_solutions, preference_info)
263
+
264
+ for i in range(len(solutions)):
265
+ if np.all(np.abs(objective_dict_to_numpy_array(problem, solutions[i])
266
+ - np.array([-0.32, 2.33, -27.85])) < acc):
267
+ print("Values close enough to the ones in the article reached. ", solutions[i])
268
+ navigated_point = solutions[i]
269
+ break