desdeo 1.2__py3-none-any.whl → 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- desdeo/__init__.py +8 -8
- desdeo/api/README.md +73 -0
- desdeo/api/__init__.py +15 -0
- desdeo/api/app.py +40 -0
- desdeo/api/config.py +69 -0
- desdeo/api/config.toml +53 -0
- desdeo/api/db.py +25 -0
- desdeo/api/db_init.py +79 -0
- desdeo/api/db_models.py +164 -0
- desdeo/api/malaga_db_init.py +27 -0
- desdeo/api/models/__init__.py +66 -0
- desdeo/api/models/archive.py +34 -0
- desdeo/api/models/preference.py +90 -0
- desdeo/api/models/problem.py +507 -0
- desdeo/api/models/reference_point_method.py +18 -0
- desdeo/api/models/session.py +46 -0
- desdeo/api/models/state.py +96 -0
- desdeo/api/models/user.py +51 -0
- desdeo/api/routers/_NAUTILUS.py +245 -0
- desdeo/api/routers/_NAUTILUS_navigator.py +233 -0
- desdeo/api/routers/_NIMBUS.py +762 -0
- desdeo/api/routers/__init__.py +5 -0
- desdeo/api/routers/problem.py +110 -0
- desdeo/api/routers/reference_point_method.py +117 -0
- desdeo/api/routers/session.py +76 -0
- desdeo/api/routers/test.py +16 -0
- desdeo/api/routers/user_authentication.py +366 -0
- desdeo/api/schema.py +94 -0
- desdeo/api/tests/__init__.py +0 -0
- desdeo/api/tests/conftest.py +59 -0
- desdeo/api/tests/test_models.py +701 -0
- desdeo/api/tests/test_routes.py +216 -0
- desdeo/api/utils/database.py +274 -0
- desdeo/api/utils/logger.py +29 -0
- desdeo/core.py +27 -0
- desdeo/emo/__init__.py +29 -0
- desdeo/emo/hooks/archivers.py +172 -0
- desdeo/emo/methods/EAs.py +418 -0
- desdeo/emo/methods/__init__.py +0 -0
- desdeo/emo/methods/bases.py +59 -0
- desdeo/emo/operators/__init__.py +1 -0
- desdeo/emo/operators/crossover.py +780 -0
- desdeo/emo/operators/evaluator.py +118 -0
- desdeo/emo/operators/generator.py +356 -0
- desdeo/emo/operators/mutation.py +1053 -0
- desdeo/emo/operators/selection.py +1036 -0
- desdeo/emo/operators/termination.py +178 -0
- desdeo/explanations/__init__.py +6 -0
- desdeo/explanations/explainer.py +100 -0
- desdeo/explanations/utils.py +90 -0
- desdeo/mcdm/__init__.py +19 -0
- desdeo/mcdm/nautili.py +345 -0
- desdeo/mcdm/nautilus.py +477 -0
- desdeo/mcdm/nautilus_navigator.py +655 -0
- desdeo/mcdm/nimbus.py +417 -0
- desdeo/mcdm/pareto_navigator.py +269 -0
- desdeo/mcdm/reference_point_method.py +116 -0
- desdeo/problem/__init__.py +79 -0
- desdeo/problem/evaluator.py +561 -0
- desdeo/problem/gurobipy_evaluator.py +562 -0
- desdeo/problem/infix_parser.py +341 -0
- desdeo/problem/json_parser.py +944 -0
- desdeo/problem/pyomo_evaluator.py +468 -0
- desdeo/problem/schema.py +1808 -0
- desdeo/problem/simulator_evaluator.py +298 -0
- desdeo/problem/sympy_evaluator.py +244 -0
- desdeo/problem/testproblems/__init__.py +73 -0
- desdeo/problem/testproblems/binh_and_korn_problem.py +88 -0
- desdeo/problem/testproblems/dtlz2_problem.py +102 -0
- desdeo/problem/testproblems/forest_problem.py +275 -0
- desdeo/problem/testproblems/knapsack_problem.py +163 -0
- desdeo/problem/testproblems/mcwb_problem.py +831 -0
- desdeo/problem/testproblems/mixed_variable_dimenrions_problem.py +83 -0
- desdeo/problem/testproblems/momip_problem.py +172 -0
- desdeo/problem/testproblems/nimbus_problem.py +143 -0
- desdeo/problem/testproblems/pareto_navigator_problem.py +89 -0
- desdeo/problem/testproblems/re_problem.py +492 -0
- desdeo/problem/testproblems/river_pollution_problem.py +434 -0
- desdeo/problem/testproblems/rocket_injector_design_problem.py +140 -0
- desdeo/problem/testproblems/simple_problem.py +351 -0
- desdeo/problem/testproblems/simulator_problem.py +92 -0
- desdeo/problem/testproblems/spanish_sustainability_problem.py +945 -0
- desdeo/problem/testproblems/zdt_problem.py +271 -0
- desdeo/problem/utils.py +245 -0
- desdeo/tools/GenerateReferencePoints.py +181 -0
- desdeo/tools/__init__.py +102 -0
- desdeo/tools/generics.py +145 -0
- desdeo/tools/gurobipy_solver_interfaces.py +258 -0
- desdeo/tools/indicators_binary.py +11 -0
- desdeo/tools/indicators_unary.py +375 -0
- desdeo/tools/interaction_schema.py +38 -0
- desdeo/tools/intersection.py +54 -0
- desdeo/tools/iterative_pareto_representer.py +99 -0
- desdeo/tools/message.py +234 -0
- desdeo/tools/ng_solver_interfaces.py +199 -0
- desdeo/tools/non_dominated_sorting.py +133 -0
- desdeo/tools/patterns.py +281 -0
- desdeo/tools/proximal_solver.py +99 -0
- desdeo/tools/pyomo_solver_interfaces.py +464 -0
- desdeo/tools/reference_vectors.py +462 -0
- desdeo/tools/scalarization.py +3138 -0
- desdeo/tools/scipy_solver_interfaces.py +454 -0
- desdeo/tools/score_bands.py +464 -0
- desdeo/tools/utils.py +320 -0
- desdeo/utopia_stuff/__init__.py +0 -0
- desdeo/utopia_stuff/data/1.json +15 -0
- desdeo/utopia_stuff/data/2.json +13 -0
- desdeo/utopia_stuff/data/3.json +15 -0
- desdeo/utopia_stuff/data/4.json +17 -0
- desdeo/utopia_stuff/data/5.json +15 -0
- desdeo/utopia_stuff/from_json.py +40 -0
- desdeo/utopia_stuff/reinit_user.py +38 -0
- desdeo/utopia_stuff/utopia_db_init.py +212 -0
- desdeo/utopia_stuff/utopia_problem.py +403 -0
- desdeo/utopia_stuff/utopia_problem_old.py +415 -0
- desdeo/utopia_stuff/utopia_reference_solutions.py +79 -0
- desdeo-2.0.0.dist-info/LICENSE +21 -0
- desdeo-2.0.0.dist-info/METADATA +168 -0
- desdeo-2.0.0.dist-info/RECORD +120 -0
- {desdeo-1.2.dist-info → desdeo-2.0.0.dist-info}/WHEEL +1 -1
- desdeo-1.2.dist-info/METADATA +0 -16
- 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
|