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
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
"""Functions related to the NAUTILUS Navigator method are defined here.
|
|
2
|
+
|
|
3
|
+
Reference of the method:
|
|
4
|
+
|
|
5
|
+
Ruiz, Ana B., et al. "NAUTILUS Navigator: free search interactive multiobjective
|
|
6
|
+
optimization without trading-off." Journal of Global Optimization 74.2 (2019):
|
|
7
|
+
213-231.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from desdeo.problem import (
|
|
14
|
+
Constraint,
|
|
15
|
+
ConstraintTypeEnum,
|
|
16
|
+
Problem,
|
|
17
|
+
ScalarizationFunction,
|
|
18
|
+
get_nadir_dict,
|
|
19
|
+
numpy_array_to_objective_dict,
|
|
20
|
+
objective_dict_to_numpy_array,
|
|
21
|
+
)
|
|
22
|
+
from desdeo.tools.generics import BaseSolver, SolverResults
|
|
23
|
+
from desdeo.tools.scalarization import (
|
|
24
|
+
add_asf_diff,
|
|
25
|
+
add_asf_nondiff,
|
|
26
|
+
add_epsilon_constraints,
|
|
27
|
+
)
|
|
28
|
+
from desdeo.tools.utils import guess_best_solver
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NAUTILUS_Response(BaseModel): # NOQA: N801
|
|
32
|
+
"""The response of the NAUTILUS Navigator method."""
|
|
33
|
+
|
|
34
|
+
step_number: int = Field(description="The step number associted with this response.")
|
|
35
|
+
distance_to_front: float = Field(
|
|
36
|
+
description=(
|
|
37
|
+
"The distance travelled to the Pareto front. "
|
|
38
|
+
"The distance is a ratio of the distances between the nadir and navigation point, and "
|
|
39
|
+
"the nadir and the reachable objective vector. The distance is given in percentage."
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
reference_point: dict | None = Field(description="The reference point used in the step.")
|
|
43
|
+
bounds: dict | None = Field(description="The user provided bounds.")
|
|
44
|
+
navigation_point: dict = Field(description="The navigation point used in the step.")
|
|
45
|
+
reachable_solution: dict | None = Field(description="The reachable solution found in the step.")
|
|
46
|
+
reachable_bounds: dict = Field(description="The reachable bounds found in the step.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NautilusNavigatorError(Exception):
|
|
50
|
+
"""Raised when an exception is encountered with procedures related to NAUTILUS Navigator."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def calculate_navigation_point(
|
|
54
|
+
problem: Problem,
|
|
55
|
+
previous_navigation_point: dict[str, float],
|
|
56
|
+
reachable_objective_vector: dict[str, float],
|
|
57
|
+
number_of_steps_remaining: int,
|
|
58
|
+
) -> dict[str, float]:
|
|
59
|
+
"""Calculates the navigation point.
|
|
60
|
+
|
|
61
|
+
The navigation point based on the previous navigation
|
|
62
|
+
point, number of navigation steps remaining, and the reachable objective
|
|
63
|
+
vector from the new navigation point.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
problem (Problem): the problem being solved.
|
|
67
|
+
previous_navigation_point (dict[str, float]): the previous navigation point.
|
|
68
|
+
reachable_objective_vector (dict[str, float]): the current reachable objective vector from the navigation point.
|
|
69
|
+
number_of_steps_remaining (int): the number of steps remaining in the navigation. Must be greater than 0.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
NautilusNavigatorError: when the given number of steps remaining is less than 0.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
list[float]: the navigation point.
|
|
76
|
+
"""
|
|
77
|
+
if number_of_steps_remaining <= 0:
|
|
78
|
+
msg = f"The given number of steps remaining ({number_of_steps_remaining=}) must be greater than 0."
|
|
79
|
+
raise NautilusNavigatorError(msg)
|
|
80
|
+
|
|
81
|
+
z_prev = objective_dict_to_numpy_array(problem, previous_navigation_point)
|
|
82
|
+
f = objective_dict_to_numpy_array(problem, reachable_objective_vector).T #
|
|
83
|
+
rs = number_of_steps_remaining
|
|
84
|
+
|
|
85
|
+
# return the new navigation point
|
|
86
|
+
z = ((rs - 1) / (rs)) * z_prev + f / rs
|
|
87
|
+
|
|
88
|
+
return numpy_array_to_objective_dict(problem, z)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def solve_reachable_bounds(
|
|
92
|
+
problem: Problem,
|
|
93
|
+
navigation_point: dict[str, float],
|
|
94
|
+
bounds: dict[str, float] | None = None,
|
|
95
|
+
solver: BaseSolver | None = None,
|
|
96
|
+
bound_th: float = 1e-3,
|
|
97
|
+
) -> tuple[dict[str, float], dict[str, float]]:
|
|
98
|
+
"""Computes the current reachable (upper and lower) bounds of the solutions in the objective space.
|
|
99
|
+
|
|
100
|
+
The reachable bound are computed based on the current navigation point. The bounds are computed by
|
|
101
|
+
solving an epsilon constraint problem.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
problem (Problem): the problem being solved.
|
|
105
|
+
navigation_point (dict[str, float]): the navigation point limiting the
|
|
106
|
+
reachable area. The key is the objective function's symbol and the value
|
|
107
|
+
the navigation point.
|
|
108
|
+
bounds (dict[str, float]): the user provided bounds preference.
|
|
109
|
+
solver (BaseSolver | None, optional): solver used to solve the problem.
|
|
110
|
+
If None, then a solver is utilized bases on the problem's properties. Defaults to None.
|
|
111
|
+
bound_th (float, optional): a threshold for comparing the bounds to the set epsilon constraints.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
NautilusNavigationError: when optimization of an epsilon constraint problem is not successful.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
tuple[dict[str, float], dict[str, float]]: a tuple of dicts, where the first dict are the lower bounds and the
|
|
118
|
+
second element the upper bounds, the key is the symbol of each objective.
|
|
119
|
+
"""
|
|
120
|
+
# If an objective is to be maximized, then the navigation point component of that objective should be
|
|
121
|
+
# multiplied by -1.
|
|
122
|
+
const_bounds = {
|
|
123
|
+
objective.symbol: -1 * navigation_point[objective.symbol]
|
|
124
|
+
if objective.maximize
|
|
125
|
+
else navigation_point[objective.symbol]
|
|
126
|
+
for objective in problem.objectives
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# if a solver creator was provided, use that, else, guess the best one
|
|
130
|
+
solver_init = guess_best_solver(problem) if solver is None else solver
|
|
131
|
+
|
|
132
|
+
lower_bounds = {}
|
|
133
|
+
upper_bounds = {}
|
|
134
|
+
for objective in problem.objectives:
|
|
135
|
+
# Lower bounds
|
|
136
|
+
eps_problem, target, eps_symbols = add_epsilon_constraints(
|
|
137
|
+
problem,
|
|
138
|
+
"target",
|
|
139
|
+
{f"{obj.symbol}": f"{obj.symbol}_eps" for obj in problem.objectives},
|
|
140
|
+
objective.symbol,
|
|
141
|
+
const_bounds,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# User bounds
|
|
145
|
+
if bounds is not None:
|
|
146
|
+
bound_constraints = [
|
|
147
|
+
Constraint(
|
|
148
|
+
name=f"User bound for {obj.symbol}",
|
|
149
|
+
symbol=f"{obj.symbol}_user",
|
|
150
|
+
func=f"{obj.symbol}_min - {bounds[obj.symbol] * (-1 if obj.maximize else 1)}",
|
|
151
|
+
cons_type=ConstraintTypeEnum.LTE,
|
|
152
|
+
is_linear=obj.is_linear,
|
|
153
|
+
is_convex=obj.is_convex,
|
|
154
|
+
is_twice_differentiable=obj.is_twice_differentiable,
|
|
155
|
+
)
|
|
156
|
+
for obj in problem.objectives
|
|
157
|
+
]
|
|
158
|
+
eps_problem = eps_problem.add_constraints(bound_constraints)
|
|
159
|
+
|
|
160
|
+
# solve
|
|
161
|
+
solver = solver_init(eps_problem)
|
|
162
|
+
res = solver.solve(target)
|
|
163
|
+
|
|
164
|
+
if not res.success:
|
|
165
|
+
# could not optimize eps problem
|
|
166
|
+
msg = (
|
|
167
|
+
f"Optimizing the epsilon constrait problem for the objective "
|
|
168
|
+
f"{objective.symbol} was not successful. Reason: {res.message}"
|
|
169
|
+
)
|
|
170
|
+
raise NautilusNavigatorError(msg)
|
|
171
|
+
|
|
172
|
+
lower_bound = res.optimal_objectives[objective.symbol]
|
|
173
|
+
|
|
174
|
+
if isinstance(lower_bound, list):
|
|
175
|
+
lower_bound = lower_bound[0]
|
|
176
|
+
|
|
177
|
+
# solver upper bounds
|
|
178
|
+
eps_problem, target, eps_symbols = add_epsilon_constraints(
|
|
179
|
+
problem,
|
|
180
|
+
"target",
|
|
181
|
+
{f"{obj.symbol}": f"{obj.symbol}_eps" for obj in problem.objectives},
|
|
182
|
+
objective.symbol,
|
|
183
|
+
const_bounds,
|
|
184
|
+
)
|
|
185
|
+
# We need to add a constrant related to the target objective to bound it to the navigation point
|
|
186
|
+
# Maybe there should be a replacement to "create_epsilon_constraints_json" that allows for this
|
|
187
|
+
# for now, we will add the constraint manually
|
|
188
|
+
# target_expr[1] = -1 # maximize the objective
|
|
189
|
+
target = "target"
|
|
190
|
+
max_objective_scal = ScalarizationFunction(
|
|
191
|
+
symbol=target,
|
|
192
|
+
name="Max objective",
|
|
193
|
+
func=["Negate", f"{objective.symbol}_min"],
|
|
194
|
+
is_linear=objective.is_linear,
|
|
195
|
+
is_convex=objective.is_convex,
|
|
196
|
+
is_twice_differentiable=objective.is_twice_differentiable,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
eps_problem = problem.add_scalarization(max_objective_scal)
|
|
200
|
+
|
|
201
|
+
bound_to_nav_constraint = Constraint(
|
|
202
|
+
symbol=f"{objective.symbol}_to_bound",
|
|
203
|
+
name=f"To bound {objective.symbol} to user bounds",
|
|
204
|
+
func=["Add", f"{objective.symbol}_min", ["Negate", const_bounds[objective.symbol]]],
|
|
205
|
+
cons_type=ConstraintTypeEnum.LTE,
|
|
206
|
+
is_linear=objective.is_linear,
|
|
207
|
+
is_convex=objective.is_convex,
|
|
208
|
+
is_twice_differentiable=objective.is_twice_differentiable,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# User bounds, add constraints
|
|
212
|
+
if bounds is not None:
|
|
213
|
+
bound_constraints = [
|
|
214
|
+
Constraint(
|
|
215
|
+
name=f"User bound for {obj.symbol}",
|
|
216
|
+
symbol=f"{obj.symbol}_user",
|
|
217
|
+
func=f"{obj.symbol}_min - {bounds[obj.symbol] * (-1 if obj.maximize else 1)}",
|
|
218
|
+
cons_type=ConstraintTypeEnum.LTE,
|
|
219
|
+
is_linear=obj.is_linear,
|
|
220
|
+
is_convex=obj.is_convex,
|
|
221
|
+
is_twice_differentiable=obj.is_twice_differentiable,
|
|
222
|
+
)
|
|
223
|
+
for obj in problem.objectives
|
|
224
|
+
]
|
|
225
|
+
eps_problem = eps_problem.add_constraints([bound_to_nav_constraint, *bound_constraints])
|
|
226
|
+
else:
|
|
227
|
+
eps_problem = eps_problem.add_constraints([bound_to_nav_constraint])
|
|
228
|
+
|
|
229
|
+
# solve
|
|
230
|
+
solver = solver_init(eps_problem)
|
|
231
|
+
res = solver.solve(target)
|
|
232
|
+
if not res.success:
|
|
233
|
+
# could not optimize eps problem
|
|
234
|
+
msg = (
|
|
235
|
+
f"Optimizing the epsilon constrait problem for the objective "
|
|
236
|
+
f"{objective.symbol} was not successful. Reason: {res.message}"
|
|
237
|
+
)
|
|
238
|
+
raise NautilusNavigatorError(msg)
|
|
239
|
+
|
|
240
|
+
upper_bound = res.optimal_objectives[objective.symbol]
|
|
241
|
+
|
|
242
|
+
if isinstance(upper_bound, list):
|
|
243
|
+
upper_bound = upper_bound[0]
|
|
244
|
+
|
|
245
|
+
if not (abs(upper_bound * (-1 if objective.maximize else 1) - const_bounds[objective.symbol]) < bound_th) and (
|
|
246
|
+
upper_bound * (-1 if objective.maximize else 1) > const_bounds[objective.symbol]
|
|
247
|
+
):
|
|
248
|
+
msg = "The upper bound is worse than the navigation point. This should not happen."
|
|
249
|
+
raise NautilusNavigatorError(msg)
|
|
250
|
+
|
|
251
|
+
# add the lower and upper bounds logically depending whether an objective is to be maximized or minimized
|
|
252
|
+
lower_bounds[objective.symbol] = lower_bound if not objective.maximize else upper_bound
|
|
253
|
+
upper_bounds[objective.symbol] = upper_bound if not objective.maximize else lower_bound
|
|
254
|
+
|
|
255
|
+
return lower_bounds, upper_bounds
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def solve_reachable_solution(
|
|
259
|
+
problem: Problem,
|
|
260
|
+
reference_point: dict[str, float],
|
|
261
|
+
previous_nav_point: dict[str, float],
|
|
262
|
+
solver: BaseSolver | None = None,
|
|
263
|
+
bounds: dict[str, float] | None = None,
|
|
264
|
+
) -> SolverResults:
|
|
265
|
+
"""Calculates the reachable solution on the Pareto optimal front.
|
|
266
|
+
|
|
267
|
+
For the calculation to make sense in the context of NAUTILUS Navigator, the reference point
|
|
268
|
+
should be bounded by the reachable bounds present at the navigation step the
|
|
269
|
+
reference point has been given.
|
|
270
|
+
|
|
271
|
+
In practice, the reachable solution is calculated by solving an achievement
|
|
272
|
+
scalarizing function.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
problem (Problem): the problem being solved.
|
|
276
|
+
reference_point (dict[str, float]): the reference point to project on the Pareto optimal front.
|
|
277
|
+
previous_nav_point (dict[str, float]): the previous navigation point. The reachable solution found
|
|
278
|
+
is always better than the previous navigation point.
|
|
279
|
+
solver (BaseSolver | None, optional): solver to solve the problem.
|
|
280
|
+
If None, then a solver is utilized bases on the problem's properties. Defaults to None.
|
|
281
|
+
bounds (dict[str, float] | None, optional): the bounds of the problem. Defaults to None.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
SolverResults: the results of the projection.
|
|
285
|
+
"""
|
|
286
|
+
# check solver
|
|
287
|
+
init_solver = guess_best_solver(problem) if solver is None else solver
|
|
288
|
+
|
|
289
|
+
# create and add scalarization function
|
|
290
|
+
if problem.is_twice_differentiable:
|
|
291
|
+
# differentiable problem
|
|
292
|
+
problem_w_asf, target = add_asf_diff(
|
|
293
|
+
problem,
|
|
294
|
+
symbol="asf",
|
|
295
|
+
reference_point=reference_point, # TODO: reference_in_aug=True
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
# non-differentiable problem
|
|
299
|
+
problem_w_asf, target = add_asf_nondiff(
|
|
300
|
+
problem, symbol="asf", reference_point=reference_point, reference_in_aug=True
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Note: We do not solve the global problem. Instead, we solve this constrained problem:
|
|
304
|
+
constraints = [
|
|
305
|
+
Constraint(
|
|
306
|
+
name=f"_const_{i+1}",
|
|
307
|
+
symbol=f"_const_{i+1}",
|
|
308
|
+
func=f"{obj.symbol}_min - {previous_nav_point[obj.symbol] * (-1 if obj.maximize else 1)}",
|
|
309
|
+
cons_type=ConstraintTypeEnum.LTE,
|
|
310
|
+
is_linear=obj.is_linear,
|
|
311
|
+
is_convex=obj.is_convex,
|
|
312
|
+
is_twice_differentiable=obj.is_twice_differentiable,
|
|
313
|
+
)
|
|
314
|
+
for i, obj in enumerate(problem.objectives)
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
if bounds is not None:
|
|
318
|
+
constraints += [
|
|
319
|
+
Constraint(
|
|
320
|
+
name=f"_const_bound_{i+1}",
|
|
321
|
+
symbol=f"_const_bound_{i+1}",
|
|
322
|
+
cons_type=ConstraintTypeEnum.LTE,
|
|
323
|
+
func=f"{obj.symbol}_min - {bounds[obj.symbol] * (-1 if obj.maximize else 1)}",
|
|
324
|
+
is_linear=obj.is_linear,
|
|
325
|
+
is_convex=obj.is_convex,
|
|
326
|
+
is_twice_differentiable=obj.is_twice_differentiable,
|
|
327
|
+
)
|
|
328
|
+
for i, obj in enumerate(problem.objectives)
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
problem_w_asf = problem_w_asf.add_constraints(constraints)
|
|
332
|
+
|
|
333
|
+
# solve the problem
|
|
334
|
+
solver = init_solver(problem_w_asf)
|
|
335
|
+
return solver.solve(target)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def calculate_distance_to_front(
|
|
339
|
+
problem: Problem, navigation_point: dict[str, float], reachable_objective_vector: dict[str, float]
|
|
340
|
+
) -> float:
|
|
341
|
+
"""Calculates the distance to the Pareto optimal front from a navigation point.
|
|
342
|
+
|
|
343
|
+
It is assumed that a nadir point is defined for the problem.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
problem (Problem): the problem being solved.
|
|
347
|
+
navigation_point (dict[str, float]): the current navigation point.
|
|
348
|
+
reachable_objective_vector (dict[str, float]): the current reachable objective vector from the navigation point.
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
NautilusNavigatorError: all or some of the components of the problem's nadir point
|
|
352
|
+
are not defined.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
float: the distance to the front.
|
|
356
|
+
"""
|
|
357
|
+
nadir_point = objective_dict_to_numpy_array(problem, get_nadir_dict(problem))
|
|
358
|
+
if None in nadir_point:
|
|
359
|
+
msg = (
|
|
360
|
+
f"Some or all the nadir values for the given problem are 'None': {nadir_point}. "
|
|
361
|
+
"The nadir point must be fully defined."
|
|
362
|
+
)
|
|
363
|
+
raise NautilusNavigatorError(msg)
|
|
364
|
+
|
|
365
|
+
z_nav = objective_dict_to_numpy_array(problem, navigation_point)
|
|
366
|
+
f = objective_dict_to_numpy_array(problem, reachable_objective_vector)
|
|
367
|
+
|
|
368
|
+
return (np.linalg.norm(z_nav - nadir_point) / np.linalg.norm(f - nadir_point)) * 100
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# NAUTILUS Navigator initializer and steppers
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def navigator_init(problem: Problem, solver: BaseSolver | None = None) -> NAUTILUS_Response:
|
|
375
|
+
"""Initializes the NAUTILUS method.
|
|
376
|
+
|
|
377
|
+
Creates the initial response of the method, which sets the navigation point to the nadir point
|
|
378
|
+
and the reachable bounds to the ideal and nadir points.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
problem (Problem): The problem to be solved.
|
|
382
|
+
solver (BaseSolver | None, optional): The solver to use. Defaults to None.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
NAUTILUS_Response: The initial response of the method.
|
|
386
|
+
"""
|
|
387
|
+
nav_point = get_nadir_dict(problem)
|
|
388
|
+
lower_bounds, upper_bounds = solve_reachable_bounds(problem, nav_point, solver=solver)
|
|
389
|
+
return NAUTILUS_Response(
|
|
390
|
+
distance_to_front=0,
|
|
391
|
+
navigation_point=nav_point,
|
|
392
|
+
reachable_bounds={"lower_bounds": lower_bounds, "upper_bounds": upper_bounds},
|
|
393
|
+
reachable_solution=None,
|
|
394
|
+
reference_point=None,
|
|
395
|
+
bounds=None,
|
|
396
|
+
step_number=0,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def navigator_step( # NOQA: PLR0913
|
|
401
|
+
problem: Problem,
|
|
402
|
+
steps_remaining: int,
|
|
403
|
+
step_number: int,
|
|
404
|
+
nav_point: dict,
|
|
405
|
+
bounds: dict | None = None,
|
|
406
|
+
solver: BaseSolver | None = None,
|
|
407
|
+
reference_point: dict | None = None,
|
|
408
|
+
reachable_solution: dict[str, float] | None = None,
|
|
409
|
+
) -> NAUTILUS_Response:
|
|
410
|
+
"""Performs a step of the NAUTILUS method.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
problem (Problem): The problem to be solved.
|
|
414
|
+
steps_remaining (int): The number of steps remaining.
|
|
415
|
+
step_number (int): The current step number. Just used for the response.
|
|
416
|
+
nav_point (dict): The current navigation point.
|
|
417
|
+
solver (BaseSolver | None, optional): The solver to use. Defaults to None.
|
|
418
|
+
reference_point (dict | None, optional): The reference point provided by the DM. Defaults to None, in which
|
|
419
|
+
case it is assumed that the DM has not changed their preference. The algorithm uses the last reachable solution,
|
|
420
|
+
which must be provided in this case.
|
|
421
|
+
bounds (dict | None, optional): The bounds of the problem provided by the DM. Defaults to None.
|
|
422
|
+
reachable_solution (dict | None, optional): The previous reachable solution. Must only be provided if the DM
|
|
423
|
+
has not changed their preference. Defaults to None.
|
|
424
|
+
|
|
425
|
+
Raises:
|
|
426
|
+
NautilusNavigatorError: If neither reference_point nor reachable_solution is provided.
|
|
427
|
+
NautilusNavigatorError: If both reference_point and reachable_solution are provided.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
NAUTILUS_Response: The response of the method after the step.
|
|
431
|
+
"""
|
|
432
|
+
if reference_point is None and reachable_solution is None:
|
|
433
|
+
raise NautilusNavigatorError("Either reference_point or reachable_solution must be provided.")
|
|
434
|
+
|
|
435
|
+
if reference_point is not None and reachable_solution is not None:
|
|
436
|
+
raise NautilusNavigatorError("Only one of reference_point or reachable_solution should be provided.")
|
|
437
|
+
|
|
438
|
+
if reference_point is not None:
|
|
439
|
+
opt_result = solve_reachable_solution(problem, reference_point, nav_point, solver, bounds=bounds)
|
|
440
|
+
reachable_point = opt_result.optimal_objectives
|
|
441
|
+
elif reachable_solution is not None:
|
|
442
|
+
reachable_point = reachable_solution
|
|
443
|
+
|
|
444
|
+
# update nav point
|
|
445
|
+
new_nav_point = calculate_navigation_point(problem, nav_point, reachable_point, steps_remaining)
|
|
446
|
+
|
|
447
|
+
# update_bounds
|
|
448
|
+
|
|
449
|
+
lower_bounds, upper_bounds = solve_reachable_bounds(problem, new_nav_point, solver=solver, bounds=bounds)
|
|
450
|
+
|
|
451
|
+
distance = calculate_distance_to_front(problem, new_nav_point, reachable_point)
|
|
452
|
+
|
|
453
|
+
if bounds is None:
|
|
454
|
+
bounds = {obj.symbol: obj.nadir for obj in problem.objectives}
|
|
455
|
+
|
|
456
|
+
return NAUTILUS_Response(
|
|
457
|
+
step_number=step_number,
|
|
458
|
+
distance_to_front=distance,
|
|
459
|
+
navigation_point=new_nav_point,
|
|
460
|
+
reachable_solution=reachable_point,
|
|
461
|
+
reference_point=reference_point,
|
|
462
|
+
reachable_bounds={"lower_bounds": lower_bounds, "upper_bounds": upper_bounds},
|
|
463
|
+
bounds=bounds,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def navigator_all_steps(
|
|
468
|
+
problem: Problem,
|
|
469
|
+
steps_remaining: int,
|
|
470
|
+
reference_point: dict,
|
|
471
|
+
previous_responses: list[NAUTILUS_Response],
|
|
472
|
+
bounds: dict | None = None,
|
|
473
|
+
solver: BaseSolver | None = None,
|
|
474
|
+
):
|
|
475
|
+
"""Performs all steps of the NAUTILUS method.
|
|
476
|
+
|
|
477
|
+
NAUTILUS needs to be initialized before calling this function. Once initialized, this function performs all
|
|
478
|
+
steps of the method. However, this method need not start from the beginning. The method conducts "steps_remaining"
|
|
479
|
+
number of steps from the last navigation point. The last navigation point is taken from the last response in
|
|
480
|
+
"previous_responses" list. The first step in this algorithm always involves recalculating the reachable solution.
|
|
481
|
+
All subsequest steps are precalculated without recalculating the reachable solution, with the assumption that the
|
|
482
|
+
reference point has not changed. It is up to the user to only show the steps that the DM thinks they have taken.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
problem (Problem): The problem to be solved.
|
|
486
|
+
steps_remaining (int): The number of steps remaining.
|
|
487
|
+
reference_point (dict): The reference point provided by the DM.
|
|
488
|
+
bounds (dict): The bounds of the problem provided by the DM.
|
|
489
|
+
previous_responses (list[NAUTILUS_Response]): The previous responses of the method.
|
|
490
|
+
solver (BaseSolver | None, optional): The solver to use. Defaults to None, in which case the
|
|
491
|
+
algorithm will guess the best solver for the problem.
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
list[NAUTILUS_Response]: The new responses of the method after all steps. Note, as only new responses are
|
|
495
|
+
returned, the length of the list is equal to "steps_remaining". The analyst should append these responses
|
|
496
|
+
to the "previous_responses" list to keep track of the entire process.
|
|
497
|
+
"""
|
|
498
|
+
responses: list[NAUTILUS_Response] = []
|
|
499
|
+
nav_point = previous_responses[-1].navigation_point
|
|
500
|
+
step_number = previous_responses[-1].step_number + 1
|
|
501
|
+
first_iteration = True
|
|
502
|
+
reachable_solution = dict
|
|
503
|
+
while steps_remaining > 0:
|
|
504
|
+
if first_iteration:
|
|
505
|
+
response = navigator_step(
|
|
506
|
+
problem,
|
|
507
|
+
steps_remaining=steps_remaining,
|
|
508
|
+
step_number=step_number,
|
|
509
|
+
nav_point=nav_point,
|
|
510
|
+
reference_point=reference_point,
|
|
511
|
+
bounds=bounds,
|
|
512
|
+
solver=solver,
|
|
513
|
+
)
|
|
514
|
+
first_iteration = False
|
|
515
|
+
else:
|
|
516
|
+
response = navigator_step(
|
|
517
|
+
problem,
|
|
518
|
+
steps_remaining=steps_remaining,
|
|
519
|
+
step_number=step_number,
|
|
520
|
+
nav_point=nav_point,
|
|
521
|
+
reachable_solution=reachable_solution,
|
|
522
|
+
bounds=bounds,
|
|
523
|
+
solver=solver,
|
|
524
|
+
)
|
|
525
|
+
response.reference_point = reference_point
|
|
526
|
+
responses.append(response)
|
|
527
|
+
reachable_solution = response.reachable_solution
|
|
528
|
+
nav_point = response.navigation_point
|
|
529
|
+
steps_remaining -= 1
|
|
530
|
+
step_number += 1
|
|
531
|
+
return responses
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def step_back_index(responses: list[NAUTILUS_Response], step_number: int) -> int:
|
|
535
|
+
"""Find the index of the response with the given step number.
|
|
536
|
+
|
|
537
|
+
Note, multiple responses can have the same step
|
|
538
|
+
number. This may happen if the DM takes a step back. In this case, the latest response with the given step number
|
|
539
|
+
is returned. Note, as we precalculate all the responses, it is up to the analyst to show the steps that the DM
|
|
540
|
+
thinks they have taken. Without this, the DM may be confused. In the worst case, the DM may take a step "back to
|
|
541
|
+
the future".
|
|
542
|
+
|
|
543
|
+
Args:
|
|
544
|
+
responses (list[NAUTILUS_Response]): Responses returned by the NAUTILUS method.
|
|
545
|
+
step_number (int): The step number to go back to.
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
int : The index of the response with the given step number.
|
|
549
|
+
"""
|
|
550
|
+
relevant_indices = [i for i, response in enumerate(responses) if response.step_number == step_number]
|
|
551
|
+
# Choose latest index
|
|
552
|
+
return relevant_indices[-1]
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def get_current_path(all_responses: list[NAUTILUS_Response]) -> list[int]:
|
|
556
|
+
"""Get the path of the current responses.
|
|
557
|
+
|
|
558
|
+
All responses may contain steps that the DM has gone back on. This function returns the path of the current active
|
|
559
|
+
path being followed by the DM. The path is a list of indices of the responses in the "all_responses" list. Note that
|
|
560
|
+
the path includes all steps until reaching the Pareto front (or whatever the last response is). It is up to the
|
|
561
|
+
analyst/GUI to only show the steps that the DM has taken.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
all_responses (list[NAUTILUS_Response]): All responses returned by the NAUTILUS method.
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
list[int]: The path of the current active responses.
|
|
568
|
+
"""
|
|
569
|
+
total_steps = all_responses[-1].step_number
|
|
570
|
+
current_index = len(all_responses) - 1
|
|
571
|
+
path: list[int] = [current_index]
|
|
572
|
+
total_steps -= 1
|
|
573
|
+
|
|
574
|
+
while total_steps >= 0:
|
|
575
|
+
found_step = False
|
|
576
|
+
while not found_step:
|
|
577
|
+
current_index -= 1
|
|
578
|
+
if all_responses[current_index].step_number == total_steps:
|
|
579
|
+
path.append(current_index)
|
|
580
|
+
found_step = True
|
|
581
|
+
total_steps -= 1
|
|
582
|
+
return list(reversed(path))
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
if __name__ == "__main__":
|
|
586
|
+
from desdeo.problem import binh_and_korn, get_ideal_dict
|
|
587
|
+
|
|
588
|
+
problem = binh_and_korn()
|
|
589
|
+
|
|
590
|
+
# initialization
|
|
591
|
+
nav_point = get_nadir_dict(problem)
|
|
592
|
+
lower_bounds = get_ideal_dict(problem)
|
|
593
|
+
upper_bounds = get_nadir_dict(problem)
|
|
594
|
+
|
|
595
|
+
step = 1
|
|
596
|
+
steps_remaining = 100
|
|
597
|
+
|
|
598
|
+
# get reference point
|
|
599
|
+
reference_point = {"f_1": 100.0, "f_2": 8.0}
|
|
600
|
+
|
|
601
|
+
# calculate reachable solution (direction)
|
|
602
|
+
opt_result = solve_reachable_solution(problem, reference_point)
|
|
603
|
+
|
|
604
|
+
assert opt_result.success
|
|
605
|
+
|
|
606
|
+
reachable_point = opt_result.optimal_objectives
|
|
607
|
+
|
|
608
|
+
# update nav point
|
|
609
|
+
nav_point = calculate_navigation_point(problem, nav_point, reachable_point, steps_remaining)
|
|
610
|
+
print(f"{nav_point=}")
|
|
611
|
+
|
|
612
|
+
# update_bounds
|
|
613
|
+
lower_bounds, upper_bounds = solve_reachable_bounds(problem, nav_point)
|
|
614
|
+
|
|
615
|
+
distance = calculate_distance_to_front(problem, nav_point, reachable_point)
|
|
616
|
+
|
|
617
|
+
step += 1
|
|
618
|
+
steps_remaining -= 1
|
|
619
|
+
|
|
620
|
+
# no new reference point, reachable point (direction) stays the same
|
|
621
|
+
# update nav point
|
|
622
|
+
nav_point = calculate_navigation_point(problem, nav_point, reachable_point, steps_remaining)
|
|
623
|
+
print(f"{nav_point=}")
|
|
624
|
+
|
|
625
|
+
# update bounds
|
|
626
|
+
lower_bounds, upper_bounds = solve_reachable_bounds(problem, nav_point)
|
|
627
|
+
|
|
628
|
+
distance = calculate_distance_to_front(problem, nav_point, reachable_point)
|
|
629
|
+
|
|
630
|
+
step += 1
|
|
631
|
+
steps_remaining -= 1
|
|
632
|
+
|
|
633
|
+
# new reference point
|
|
634
|
+
reference_point = {"f_1": 80.0, "f_2": 9.0}
|
|
635
|
+
|
|
636
|
+
# calculate reachable solution (direction)
|
|
637
|
+
opt_result = solve_reachable_solution(problem, reference_point)
|
|
638
|
+
|
|
639
|
+
assert opt_result.success
|
|
640
|
+
|
|
641
|
+
reachable_point = opt_result.optimal_objectives
|
|
642
|
+
|
|
643
|
+
# update nav point
|
|
644
|
+
nav_point = calculate_navigation_point(problem, nav_point, reachable_point, steps_remaining)
|
|
645
|
+
print(f"{nav_point=}")
|
|
646
|
+
|
|
647
|
+
# update_bounds
|
|
648
|
+
lower_bounds, upper_bounds = solve_reachable_bounds(problem, nav_point)
|
|
649
|
+
|
|
650
|
+
distance = calculate_distance_to_front(problem, nav_point, reachable_point)
|
|
651
|
+
|
|
652
|
+
step += 1
|
|
653
|
+
steps_remaining -= 1
|
|
654
|
+
|
|
655
|
+
# etc...
|