desdeo 2.0.0__py3-none-any.whl → 2.1.1__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/adm/ADMAfsar.py +551 -0
- desdeo/adm/ADMChen.py +414 -0
- desdeo/adm/BaseADM.py +119 -0
- desdeo/adm/__init__.py +11 -0
- desdeo/api/__init__.py +6 -6
- desdeo/api/app.py +38 -28
- desdeo/api/config.py +65 -44
- desdeo/api/config.toml +23 -12
- desdeo/api/db.py +10 -8
- desdeo/api/db_init.py +12 -6
- desdeo/api/models/__init__.py +220 -20
- desdeo/api/models/archive.py +16 -27
- desdeo/api/models/emo.py +128 -0
- desdeo/api/models/enautilus.py +69 -0
- desdeo/api/models/gdm/gdm_aggregate.py +139 -0
- desdeo/api/models/gdm/gdm_base.py +69 -0
- desdeo/api/models/gdm/gdm_score_bands.py +114 -0
- desdeo/api/models/gdm/gnimbus.py +138 -0
- desdeo/api/models/generic.py +104 -0
- desdeo/api/models/generic_states.py +401 -0
- desdeo/api/models/nimbus.py +158 -0
- desdeo/api/models/preference.py +44 -6
- desdeo/api/models/problem.py +274 -64
- desdeo/api/models/session.py +4 -1
- desdeo/api/models/state.py +419 -52
- desdeo/api/models/user.py +7 -6
- desdeo/api/models/utopia.py +25 -0
- desdeo/api/routers/_EMO.backup +309 -0
- desdeo/api/routers/_NIMBUS.py +6 -3
- desdeo/api/routers/emo.py +497 -0
- desdeo/api/routers/enautilus.py +237 -0
- desdeo/api/routers/gdm/gdm_aggregate.py +234 -0
- desdeo/api/routers/gdm/gdm_base.py +420 -0
- desdeo/api/routers/gdm/gdm_score_bands/gdm_score_bands_manager.py +398 -0
- desdeo/api/routers/gdm/gdm_score_bands/gdm_score_bands_routers.py +377 -0
- desdeo/api/routers/gdm/gnimbus/gnimbus_manager.py +698 -0
- desdeo/api/routers/gdm/gnimbus/gnimbus_routers.py +591 -0
- desdeo/api/routers/generic.py +233 -0
- desdeo/api/routers/nimbus.py +705 -0
- desdeo/api/routers/problem.py +201 -4
- desdeo/api/routers/reference_point_method.py +20 -44
- desdeo/api/routers/session.py +50 -26
- desdeo/api/routers/user_authentication.py +180 -26
- desdeo/api/routers/utils.py +187 -0
- desdeo/api/routers/utopia.py +230 -0
- desdeo/api/schema.py +10 -4
- desdeo/api/tests/conftest.py +94 -2
- desdeo/api/tests/test_enautilus.py +330 -0
- desdeo/api/tests/test_models.py +550 -72
- desdeo/api/tests/test_routes.py +902 -43
- desdeo/api/utils/_database.py +263 -0
- desdeo/api/utils/database.py +28 -266
- desdeo/api/utils/emo_database.py +40 -0
- desdeo/core.py +7 -0
- desdeo/emo/__init__.py +154 -24
- desdeo/emo/hooks/archivers.py +18 -2
- desdeo/emo/methods/EAs.py +128 -5
- desdeo/emo/methods/bases.py +9 -56
- desdeo/emo/methods/templates.py +111 -0
- desdeo/emo/operators/crossover.py +544 -42
- desdeo/emo/operators/evaluator.py +10 -14
- desdeo/emo/operators/generator.py +127 -24
- desdeo/emo/operators/mutation.py +212 -41
- desdeo/emo/operators/scalar_selection.py +202 -0
- desdeo/emo/operators/selection.py +956 -214
- desdeo/emo/operators/termination.py +124 -16
- desdeo/emo/options/__init__.py +108 -0
- desdeo/emo/options/algorithms.py +435 -0
- desdeo/emo/options/crossover.py +164 -0
- desdeo/emo/options/generator.py +131 -0
- desdeo/emo/options/mutation.py +260 -0
- desdeo/emo/options/repair.py +61 -0
- desdeo/emo/options/scalar_selection.py +66 -0
- desdeo/emo/options/selection.py +127 -0
- desdeo/emo/options/templates.py +383 -0
- desdeo/emo/options/termination.py +143 -0
- desdeo/gdm/__init__.py +22 -0
- desdeo/gdm/gdmtools.py +45 -0
- desdeo/gdm/score_bands.py +114 -0
- desdeo/gdm/voting_rules.py +50 -0
- desdeo/mcdm/__init__.py +23 -1
- desdeo/mcdm/enautilus.py +338 -0
- desdeo/mcdm/gnimbus.py +484 -0
- desdeo/mcdm/nautilus_navigator.py +7 -6
- desdeo/mcdm/reference_point_method.py +70 -0
- desdeo/problem/__init__.py +16 -11
- desdeo/problem/evaluator.py +4 -5
- desdeo/problem/external/__init__.py +18 -0
- desdeo/problem/external/core.py +356 -0
- desdeo/problem/external/pymoo_provider.py +266 -0
- desdeo/problem/external/runtime.py +44 -0
- desdeo/problem/gurobipy_evaluator.py +37 -12
- desdeo/problem/infix_parser.py +1 -16
- desdeo/problem/json_parser.py +7 -11
- desdeo/problem/pyomo_evaluator.py +25 -6
- desdeo/problem/schema.py +73 -55
- desdeo/problem/simulator_evaluator.py +65 -15
- desdeo/problem/testproblems/__init__.py +26 -11
- desdeo/problem/testproblems/benchmarks_server.py +120 -0
- desdeo/problem/testproblems/cake_problem.py +185 -0
- desdeo/problem/testproblems/dmitry_forest_problem_discrete.py +71 -0
- desdeo/problem/testproblems/forest_problem.py +77 -69
- desdeo/problem/testproblems/multi_valued_constraints.py +119 -0
- desdeo/problem/testproblems/{river_pollution_problem.py → river_pollution_problems.py} +28 -22
- desdeo/problem/testproblems/single_objective.py +289 -0
- desdeo/problem/testproblems/zdt_problem.py +4 -1
- desdeo/problem/utils.py +1 -1
- desdeo/tools/__init__.py +39 -21
- desdeo/tools/desc_gen.py +22 -0
- desdeo/tools/generics.py +22 -2
- desdeo/tools/group_scalarization.py +3090 -0
- desdeo/tools/indicators_binary.py +107 -1
- desdeo/tools/indicators_unary.py +3 -16
- desdeo/tools/message.py +33 -2
- desdeo/tools/non_dominated_sorting.py +4 -3
- desdeo/tools/patterns.py +9 -7
- desdeo/tools/pyomo_solver_interfaces.py +49 -36
- desdeo/tools/reference_vectors.py +118 -351
- desdeo/tools/scalarization.py +340 -1413
- desdeo/tools/score_bands.py +491 -328
- desdeo/tools/utils.py +117 -49
- desdeo/tools/visualizations.py +67 -0
- desdeo/utopia_stuff/utopia_problem.py +1 -1
- desdeo/utopia_stuff/utopia_problem_old.py +1 -1
- {desdeo-2.0.0.dist-info → desdeo-2.1.1.dist-info}/METADATA +47 -30
- desdeo-2.1.1.dist-info/RECORD +180 -0
- {desdeo-2.0.0.dist-info → desdeo-2.1.1.dist-info}/WHEEL +1 -1
- desdeo-2.0.0.dist-info/RECORD +0 -120
- /desdeo/api/utils/{logger.py → _logger.py} +0 -0
- {desdeo-2.0.0.dist-info → desdeo-2.1.1.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Implements a interactive SCORE bands based GDM."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
from desdeo.gdm.voting_rules import consensus_rule
|
|
9
|
+
from desdeo.tools.score_bands import SCOREBandsConfig, SCOREBandsResult, score_json
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SCOREBandsGDMConfig(BaseModel):
|
|
13
|
+
"""Configuration for the SCORE bands based GDM."""
|
|
14
|
+
|
|
15
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
16
|
+
|
|
17
|
+
score_bands_config: SCOREBandsConfig = Field(default_factory=lambda: SCOREBandsConfig())
|
|
18
|
+
"""Configuration for the SCORE bands method."""
|
|
19
|
+
minimum_votes: int = Field(default=1, gt=0)
|
|
20
|
+
"""Minimum number of votes required to select a cluster."""
|
|
21
|
+
from_iteration: int | None
|
|
22
|
+
"""The iteration number from which to consider the clusters. Set to None if method is initializing."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SCOREBandsGDMResult(BaseModel):
|
|
26
|
+
"""Result of the SCORE bands based GDM."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
29
|
+
|
|
30
|
+
votes: dict[str, int] | None = Field(default=None)
|
|
31
|
+
"""The votes given by the decision makers."""
|
|
32
|
+
score_bands_result: SCOREBandsResult
|
|
33
|
+
"""Result of the SCORE bands method."""
|
|
34
|
+
relevant_ids: list[int]
|
|
35
|
+
"""IDs of the relevant solutions in the current iteration. Assumes that data is not modified between iterations."""
|
|
36
|
+
# If the data keeps changing, we need to store the actual data instead of just the IDs.
|
|
37
|
+
iteration: int
|
|
38
|
+
previous_iteration: int | None
|
|
39
|
+
"""The previous iteration number, if any."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def score_bands_gdm(
|
|
43
|
+
data: pl.DataFrame,
|
|
44
|
+
config: SCOREBandsGDMConfig,
|
|
45
|
+
state: list[SCOREBandsGDMResult],
|
|
46
|
+
votes: dict[str, int] | None = None,
|
|
47
|
+
) -> list[SCOREBandsGDMResult]:
|
|
48
|
+
"""Run the SCORE bands based interactive GDM.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
data (pl.DataFrame): The data to run the GDM on.
|
|
52
|
+
config (SCOREBandsGDMConfig): Configuration for the GDM.
|
|
53
|
+
state (list[SCOREBandsGDMResult]): List of previous state of the GDM. Empty list if first iteration.
|
|
54
|
+
votes (dict[str, int] | None, optional): Votes from the decision makers. Defaults to None.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: Both state and votes must be provided or neither.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
list[SCOREBandsGDMResult]: The updated state of the GDM.
|
|
61
|
+
"""
|
|
62
|
+
if bool(state) != bool(votes):
|
|
63
|
+
raise ValueError("Both state and votes must be provided or neither.")
|
|
64
|
+
if votes is None:
|
|
65
|
+
# First iteration. No votes yet.
|
|
66
|
+
score_bands_result = score_json(data, config.score_bands_config)
|
|
67
|
+
return [
|
|
68
|
+
SCOREBandsGDMResult(
|
|
69
|
+
score_bands_result=score_bands_result,
|
|
70
|
+
relevant_ids=list(range(len(data))),
|
|
71
|
+
iteration=1,
|
|
72
|
+
previous_iteration=None,
|
|
73
|
+
)
|
|
74
|
+
]
|
|
75
|
+
if not state:
|
|
76
|
+
raise ValueError("State must be provided if votes are provided.")
|
|
77
|
+
elif config.from_iteration is None:
|
|
78
|
+
raise ValueError("from_iteration must be set in the config for subsequent iterations.")
|
|
79
|
+
|
|
80
|
+
winning_clusters = consensus_rule(votes, config.minimum_votes)
|
|
81
|
+
|
|
82
|
+
index_column_name = "index"
|
|
83
|
+
if index_column_name in data.columns:
|
|
84
|
+
index_column_name = "index_"
|
|
85
|
+
cluster_column_name = "cluster"
|
|
86
|
+
if cluster_column_name in data.columns:
|
|
87
|
+
cluster_column_name = "cluster_"
|
|
88
|
+
|
|
89
|
+
current_iteration = state[-1].iteration + 1
|
|
90
|
+
|
|
91
|
+
clusters = state[config.from_iteration - 1].score_bands_result.clusters
|
|
92
|
+
relevant_data = (
|
|
93
|
+
data.with_row_index(name=index_column_name) # Add index column
|
|
94
|
+
.filter(
|
|
95
|
+
pl.col(index_column_name).is_in(state[config.from_iteration - 1].relevant_ids)
|
|
96
|
+
) # Get the solutions from previous iteration
|
|
97
|
+
.with_columns(pl.Series(cluster_column_name, clusters)) # Add clustering information from last iteration
|
|
98
|
+
.filter(pl.col(cluster_column_name).is_in(winning_clusters)) # Keep only winning clusters
|
|
99
|
+
.drop(cluster_column_name) # Drop cluster column
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
relevant_ids = relevant_data[index_column_name].to_list()
|
|
103
|
+
relevant_data = relevant_data.drop(index_column_name) # Drop index column
|
|
104
|
+
|
|
105
|
+
return [
|
|
106
|
+
*state,
|
|
107
|
+
SCOREBandsGDMResult(
|
|
108
|
+
votes=votes,
|
|
109
|
+
score_bands_result=score_json(relevant_data, config.score_bands_config),
|
|
110
|
+
relevant_ids=relevant_ids,
|
|
111
|
+
iteration=current_iteration,
|
|
112
|
+
previous_iteration=config.from_iteration,
|
|
113
|
+
),
|
|
114
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""This module contains voting rules for group decision making such as majority rule."""
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def majority_rule(votes: dict[str, int]) -> int | None:
|
|
7
|
+
"""Choose the option that has more than half of the votes.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
votes (dict[str, int]): A dictionary mapping voter IDs to their votes.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
int | None: The option that has more than half of the votes, or None if no such option exists.
|
|
14
|
+
"""
|
|
15
|
+
counts = Counter(votes.values())
|
|
16
|
+
all_votes = sum(counts.values())
|
|
17
|
+
for vote, c in counts.items():
|
|
18
|
+
if c > all_votes // 2:
|
|
19
|
+
return vote
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def plurality_rule(votes: dict[str, int]) -> list[int]:
|
|
24
|
+
"""Choose the option that has the most votes.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
votes (dict[str, int]): A dictionary mapping voter IDs to their votes.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
list[int]: A list of options that have the most votes (in case of a tie).
|
|
31
|
+
"""
|
|
32
|
+
counts = Counter(votes.values())
|
|
33
|
+
max_votes = max(counts.values())
|
|
34
|
+
return [vote for vote, c in counts.items() if c == max_votes]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def consensus_rule(votes: dict[str, int], min_votes: int) -> list[int]:
|
|
38
|
+
"""Choose all options that have at least min_votes votes.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
votes (dict[str, int]): A dictionary mapping voter IDs to their votes.
|
|
42
|
+
min_votes (int): The minimum number of votes required for an option to be selected.
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
if min_votes <= 0:
|
|
46
|
+
raise ValueError("min_votes must be greater than 0.")
|
|
47
|
+
if min_votes > len(votes):
|
|
48
|
+
raise ValueError("min_votes cannot be greater than the number of voters.")
|
|
49
|
+
counts = Counter(votes.values())
|
|
50
|
+
return [vote for vote, c in counts.items() if c >= min_votes]
|
desdeo/mcdm/__init__.py
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
1
|
"""Imports available from the desdeo-mcdm package."""
|
|
2
2
|
|
|
3
3
|
__all__ = [
|
|
4
|
+
"ENautilusResult",
|
|
4
5
|
"NimbusError",
|
|
6
|
+
"enautilus_get_representative_solutions",
|
|
7
|
+
"enautilus_step",
|
|
8
|
+
"calculate_closeness",
|
|
9
|
+
"calculate_intermediate_points",
|
|
10
|
+
"calculate_lower_bounds",
|
|
11
|
+
"calculate_reachable_subset",
|
|
5
12
|
"generate_starting_point",
|
|
6
13
|
"infer_classifications",
|
|
14
|
+
"prune_by_average_linkage",
|
|
7
15
|
"solve_intermediate_solutions",
|
|
8
16
|
"solve_sub_problems",
|
|
17
|
+
"solve_group_sub_problems",
|
|
18
|
+
"voting_procedure",
|
|
9
19
|
"rpm_solve_solutions",
|
|
20
|
+
"rpm_intermediate_solutions",
|
|
10
21
|
]
|
|
11
22
|
|
|
23
|
+
from .enautilus import (
|
|
24
|
+
ENautilusResult,
|
|
25
|
+
calculate_closeness,
|
|
26
|
+
calculate_intermediate_points,
|
|
27
|
+
calculate_lower_bounds,
|
|
28
|
+
calculate_reachable_subset,
|
|
29
|
+
enautilus_get_representative_solutions,
|
|
30
|
+
enautilus_step,
|
|
31
|
+
prune_by_average_linkage,
|
|
32
|
+
)
|
|
12
33
|
from .nimbus import (
|
|
13
34
|
NimbusError,
|
|
14
35
|
generate_starting_point,
|
|
@@ -16,4 +37,5 @@ from .nimbus import (
|
|
|
16
37
|
solve_intermediate_solutions,
|
|
17
38
|
solve_sub_problems,
|
|
18
39
|
)
|
|
19
|
-
from .
|
|
40
|
+
from .gnimbus import solve_group_sub_problems, voting_procedure
|
|
41
|
+
from .reference_point_method import rpm_solve_solutions, rpm_intermediate_solutions
|
desdeo/mcdm/enautilus.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Functions related to the E-NAUTILUS method are defined here.
|
|
2
|
+
|
|
3
|
+
Reference of the method:
|
|
4
|
+
|
|
5
|
+
Ruiz, A. B., Sindhya, K., Miettinen, K., Ruiz, F., & Luque, M. (2015).
|
|
6
|
+
E-NAUTILUS: A decision support system for complex multiobjective optimization
|
|
7
|
+
problems based on the NAUTILUS method. European Journal of Operational Research,
|
|
8
|
+
246(1), 218-231.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import polars as pl
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
from scipy.cluster.hierarchy import fcluster, linkage
|
|
15
|
+
from scipy.spatial.distance import pdist
|
|
16
|
+
|
|
17
|
+
from desdeo.problem import (
|
|
18
|
+
Problem,
|
|
19
|
+
numpy_array_to_objective_dict,
|
|
20
|
+
objective_dict_to_numpy_array,
|
|
21
|
+
)
|
|
22
|
+
from desdeo.tools import SolverResults, flip_maximized_objective_values
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ENautilusResult(BaseModel):
|
|
26
|
+
"""The result of an iteration of the E-NAUTILUS method."""
|
|
27
|
+
|
|
28
|
+
current_iteration: int = Field(description="Number of the current iteration.")
|
|
29
|
+
iterations_left: int = Field(description="Number of iterations left.")
|
|
30
|
+
intermediate_points: list[dict[str, float]] = Field(description="New intermediate points")
|
|
31
|
+
reachable_best_bounds: list[dict[str, float]] = Field(
|
|
32
|
+
description="Best bounds of the objective function values reachable from each intermediate point."
|
|
33
|
+
)
|
|
34
|
+
reachable_worst_bounds: list[dict[str, float]] = Field(
|
|
35
|
+
description="Worst bounds of the objective function values reachable from each intermediate point."
|
|
36
|
+
)
|
|
37
|
+
closeness_measures: list[float] = Field(description="Closeness measures of each intermediate point.")
|
|
38
|
+
reachable_point_indices: list[list[int]] = Field(
|
|
39
|
+
description="Indices of the reachable points from each intermediate point."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def enautilus_get_representative_solutions(
|
|
44
|
+
problem: Problem, result: ENautilusResult, non_dominated_points: pl.DataFrame
|
|
45
|
+
) -> list[SolverResults]:
|
|
46
|
+
"""Returns the solution corresponding to the intermediate points.
|
|
47
|
+
|
|
48
|
+
The representative points are selected based on the current intermediate points.
|
|
49
|
+
If the number of iterations left is 0, then the intermediate and representative points
|
|
50
|
+
are equal.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
problem (Problem): the problem being solved.
|
|
54
|
+
result (ENautilusResult): an ENautilusResponse returned by `enautilus_step`.
|
|
55
|
+
non_dominated_points (pl.DataFrame): a dataframe from which the
|
|
56
|
+
representative solutions are taken.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
SolverResults: full information about the solutions. If information
|
|
60
|
+
other than just objective function values are expected, then the
|
|
61
|
+
supplied `non_dominated_points` should contain this information.
|
|
62
|
+
"""
|
|
63
|
+
obj_syms = [obj.symbol for obj in problem.objectives]
|
|
64
|
+
var_syms = [var.symbol for var in problem.variables]
|
|
65
|
+
const_syms = [con.symbol for con in problem.constraints] if problem.constraints else None
|
|
66
|
+
extra_syms = [extra.symbol for extra in problem.extra_funcs] if problem.extra_funcs else None
|
|
67
|
+
scal_syms = [scal.symbol for scal in problem.scalarization_funcs] if problem.scalarization_funcs else None
|
|
68
|
+
|
|
69
|
+
# Objective matrix (rows = ND points, cols = objectives, original senses)
|
|
70
|
+
obj_matrix = non_dominated_points.select(obj_syms).to_numpy()
|
|
71
|
+
|
|
72
|
+
solver_results: list[SolverResults] = []
|
|
73
|
+
|
|
74
|
+
for interm in result.intermediate_points:
|
|
75
|
+
interm_vec = np.array([interm[sym] for sym in obj_syms], dtype=float)
|
|
76
|
+
|
|
77
|
+
# Find index of closest ND point (Euclidean distance)
|
|
78
|
+
idx = int(np.argmin(np.linalg.norm(obj_matrix - interm_vec, axis=1)))
|
|
79
|
+
|
|
80
|
+
row = non_dominated_points[idx]
|
|
81
|
+
|
|
82
|
+
var_dict = {sym: row[sym] for sym in var_syms if sym in row}
|
|
83
|
+
obj_dict = {sym: row[sym] for sym in obj_syms}
|
|
84
|
+
const_dict = {sym: row[sym] for sym in const_syms if sym in row} if const_syms is not None else None
|
|
85
|
+
extra_dict = {sym: row[sym] for sym in extra_syms if sym in row} if extra_syms is not None else None
|
|
86
|
+
scal_dict = {sym: row[sym] for sym in scal_syms if sym in row} if scal_syms is not None else None
|
|
87
|
+
|
|
88
|
+
solver_results.append(
|
|
89
|
+
SolverResults(
|
|
90
|
+
optimal_variables=var_dict,
|
|
91
|
+
optimal_objectives=obj_dict,
|
|
92
|
+
constraint_values=const_dict,
|
|
93
|
+
extra_func_values=extra_dict,
|
|
94
|
+
scalarization_values=scal_dict,
|
|
95
|
+
success=True,
|
|
96
|
+
message="E-NAUTILUS: nearest non-dominated point selected for intermediate point.",
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return solver_results
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def enautilus_step( # noqa: PLR0913
|
|
104
|
+
problem: Problem,
|
|
105
|
+
non_dominated_points: pl.DataFrame | dict[str, float],
|
|
106
|
+
current_iteration: int,
|
|
107
|
+
iterations_left: int,
|
|
108
|
+
selected_point: dict[str, float],
|
|
109
|
+
reachable_point_indices: list[int],
|
|
110
|
+
number_of_intermediate_points: int,
|
|
111
|
+
) -> ENautilusResult:
|
|
112
|
+
"""Compute one iteration of the E-NAUTILUS method.
|
|
113
|
+
|
|
114
|
+
It is assumed that information from a previous iteration (selected point,
|
|
115
|
+
etc.) is available either from a previous iteration of E-NAUTILUS, or if
|
|
116
|
+
this is the first iteration, then the selected (intermediate) point
|
|
117
|
+
`selected_point` should be the approximated nadir point from
|
|
118
|
+
`non_dominated_points`. In this case, the `reachable_point_indices` should
|
|
119
|
+
cover the whole of `non_dominated_points`. After the first iteration, all
|
|
120
|
+
the information for computing the next iteration is always available from
|
|
121
|
+
the previous iteration's result of this function (plus the `selected_point`
|
|
122
|
+
provided by e.g., a decision maker).
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
problem (Problem): the problem being solved. Used mainly for manipulating the other arguments.
|
|
126
|
+
non_dominated_points (pl.DataFrame): a set of non-dominated points
|
|
127
|
+
approximating the Pareto front of `Problem`. This should be a Polars
|
|
128
|
+
dataframe with at least columns that match the objective function
|
|
129
|
+
symbols in `Problem` and the corresponding minimization value column.
|
|
130
|
+
I.e., for an objective with symbol 'f1' the dataframe should have the
|
|
131
|
+
symbols 'f1' and 'f1_min', where the column 'f1_min has the
|
|
132
|
+
corresponding values of 'f1', but assuming minimization (N.B. if 'f1' is
|
|
133
|
+
minimized, then 'f1_min' would have identical values as 'f1'). If provided
|
|
134
|
+
as a `dict`, this will be converted to a polars dataframe.
|
|
135
|
+
current_iteration (int): the number of the current iteration. For the first iteration, this should be zero.
|
|
136
|
+
iterations_left (int): how many iteration are left (counting the current one).
|
|
137
|
+
selected_point (dict[str, float]): the selected intermediate point in
|
|
138
|
+
the previous iteration. If this is the first iteration, then this should
|
|
139
|
+
be the nadir point approximated from `non_dominated_points`.
|
|
140
|
+
reachable_point_indices (list[int]): the indices of the points in
|
|
141
|
+
`non_dominated_points` that are reachable from
|
|
142
|
+
`current_iteration_point`.
|
|
143
|
+
number_of_intermediate_points (int): how many intermediate points are generated.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
ENautilusResult: the result of the iteration.
|
|
147
|
+
"""
|
|
148
|
+
# treat everything as minimized
|
|
149
|
+
# selected point as numpy array, correct for minimization
|
|
150
|
+
z_h = objective_dict_to_numpy_array(problem, flip_maximized_objective_values(problem, selected_point))
|
|
151
|
+
|
|
152
|
+
# subset of reachable solutions, take _min column
|
|
153
|
+
if isinstance(non_dominated_points, dict):
|
|
154
|
+
# dict converted to Polars dataframe
|
|
155
|
+
_non_dominated_points = pl.DataFrame(non_dominated_points)
|
|
156
|
+
else:
|
|
157
|
+
# already Polars dataframe
|
|
158
|
+
_non_dominated_points = non_dominated_points
|
|
159
|
+
|
|
160
|
+
non_dom_objectives = _non_dominated_points[[f"{obj.symbol}_min" for obj in problem.objectives]].to_numpy()
|
|
161
|
+
p_h = non_dom_objectives[reachable_point_indices]
|
|
162
|
+
|
|
163
|
+
# estimate nadir from non-dominated points, treating as minimized problem
|
|
164
|
+
z_nadir = non_dom_objectives.max(axis=0)
|
|
165
|
+
|
|
166
|
+
# compute representative points
|
|
167
|
+
representative_points = prune_by_average_linkage(p_h, number_of_intermediate_points)
|
|
168
|
+
|
|
169
|
+
# calculate intermediate points
|
|
170
|
+
intermediate_points = calculate_intermediate_points(z_h, representative_points, iterations_left)
|
|
171
|
+
|
|
172
|
+
# calculate lower bounds
|
|
173
|
+
intermediate_lower_bounds = [
|
|
174
|
+
calculate_lower_bounds(p_h, intermediate_point) for intermediate_point in intermediate_points
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
# calculate closeness measures
|
|
178
|
+
closeness_measures = [
|
|
179
|
+
calculate_closeness(intermediate_point, z_nadir, representative_point)
|
|
180
|
+
for (intermediate_point, representative_point) in zip(intermediate_points, representative_points, strict=True)
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
# calculate the indices of the reachable points for each intermediate point
|
|
184
|
+
reachable_from_intermediate = [
|
|
185
|
+
calculate_reachable_subset(non_dom_objectives, reachable_point_indices, lower_bounds, interm)
|
|
186
|
+
for lower_bounds, interm in zip(intermediate_lower_bounds, intermediate_points, strict=True)
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
best_bounds = [
|
|
190
|
+
flip_maximized_objective_values(problem, numpy_array_to_objective_dict(problem, bounds))
|
|
191
|
+
for bounds in intermediate_lower_bounds
|
|
192
|
+
]
|
|
193
|
+
worst_bounds = [
|
|
194
|
+
flip_maximized_objective_values(problem, numpy_array_to_objective_dict(problem, point))
|
|
195
|
+
for point in intermediate_points
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
corrected_intermediate_points = [
|
|
199
|
+
flip_maximized_objective_values(problem, numpy_array_to_objective_dict(problem, point))
|
|
200
|
+
for point in intermediate_points
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
return ENautilusResult(
|
|
204
|
+
current_iteration=current_iteration + 1,
|
|
205
|
+
iterations_left=iterations_left - 1,
|
|
206
|
+
intermediate_points=corrected_intermediate_points,
|
|
207
|
+
reachable_best_bounds=best_bounds,
|
|
208
|
+
reachable_worst_bounds=worst_bounds,
|
|
209
|
+
closeness_measures=closeness_measures,
|
|
210
|
+
reachable_point_indices=reachable_from_intermediate,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def prune_by_average_linkage(non_dominated_points: np.ndarray, k: int) -> np.ndarray:
|
|
215
|
+
"""Prune a set of non-dominated points using average linkage clustering (Morse, 1980).
|
|
216
|
+
|
|
217
|
+
This is used to calculate the representative solutions in E-NAUTILUS.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
non_dominated_points (np.ndarray): an array of non-dominated points in objective space.
|
|
221
|
+
k (int): Number of representative points to retain.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
np.ndarray: an array of representative points.
|
|
225
|
+
"""
|
|
226
|
+
if len(non_dominated_points) <= k:
|
|
227
|
+
# no need to prune
|
|
228
|
+
return non_dominated_points
|
|
229
|
+
|
|
230
|
+
# Compute pairwise distances
|
|
231
|
+
distances = pdist(non_dominated_points, metric="euclidean")
|
|
232
|
+
|
|
233
|
+
# Hierarchical clustering using average linkage
|
|
234
|
+
z = linkage(distances, method="average")
|
|
235
|
+
|
|
236
|
+
# Cut tree to form k clusters
|
|
237
|
+
cluster_labels = fcluster(z, k, criterion="maxclust")
|
|
238
|
+
|
|
239
|
+
# For each cluster, choose the point closest to the centroid
|
|
240
|
+
representatives = []
|
|
241
|
+
for cluster_id in range(1, k + 1):
|
|
242
|
+
cluster_points = non_dominated_points[cluster_labels == cluster_id]
|
|
243
|
+
centroid = cluster_points.mean(axis=0)
|
|
244
|
+
closest_idx = np.argmin(np.linalg.norm(cluster_points - centroid, axis=1))
|
|
245
|
+
representatives.append(cluster_points[closest_idx])
|
|
246
|
+
|
|
247
|
+
return np.array(representatives)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def calculate_intermediate_points(
|
|
251
|
+
z_previous: np.ndarray, zs_representatives: np.ndarray, iterations_left: int
|
|
252
|
+
) -> np.ndarray:
|
|
253
|
+
"""Calculates the intermediate points to be shown to the decision maker at each iteration.
|
|
254
|
+
|
|
255
|
+
The number of returned points depends on how many `zs_representative points` are supplied.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
z_previous (np.ndarray): the point selected by the decision maker in the previous iteration.
|
|
259
|
+
zs_representatives (np.ndarray): the representative solutions at the current iteration.
|
|
260
|
+
iterations_left (int): the number of iterations left (including the current one).
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
np.ndarray: an array of intermediate points.
|
|
264
|
+
"""
|
|
265
|
+
return ((iterations_left - 1) / iterations_left) * z_previous + (1 / iterations_left) * zs_representatives
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def calculate_reachable_subset(
|
|
269
|
+
non_dominated_points: np.ndarray, reachable_indices: np.ndarray, lower_bounds: np.ndarray, z_preferred: np.ndarray
|
|
270
|
+
) -> list[int]:
|
|
271
|
+
"""Calculates the reachable subset on a non-dominated set from a selected intermediate point.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
non_dominated_points (np.ndarray): the original set of non-dominated points.
|
|
275
|
+
reachable_indices (np.ndarray): the currently reachable indices, which
|
|
276
|
+
will be used to select the new reachable points.
|
|
277
|
+
lower_bounds (np.ndarray): the lower bounds of the reachable subset of non-dominates points.
|
|
278
|
+
z_preferred (np.ndarray): the selected intermediate point subject to the reachable subset is calculated.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
list[int]: the indices of the reachable solutions
|
|
282
|
+
"""
|
|
283
|
+
return [
|
|
284
|
+
i
|
|
285
|
+
for i, z in enumerate(non_dominated_points)
|
|
286
|
+
if np.all(lower_bounds <= z) and np.all(z <= z_preferred) and i in reachable_indices
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def calculate_lower_bounds(non_dominated_points: np.ndarray, z_intermediate: np.ndarray) -> np.ndarray:
|
|
291
|
+
"""Calculates the lower bounds of reachable solutions from an intermediate point.
|
|
292
|
+
|
|
293
|
+
The lower bounds are calculated by solving an epsilon-constraint problem
|
|
294
|
+
with the epsilon values taken from the intermediate point.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
non_dominated_points (np.ndarray): a set of non-dominated points
|
|
298
|
+
according to which the reachable values are computed.
|
|
299
|
+
z_intermediate (np.ndarray): the intermediate point according to which
|
|
300
|
+
the lower bounds are calculated.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
np.ndarray: the lower bounds of reachable solutions on the non-dominated
|
|
304
|
+
set based from the intermediate point.
|
|
305
|
+
"""
|
|
306
|
+
k = non_dominated_points.shape[1]
|
|
307
|
+
bounds = []
|
|
308
|
+
|
|
309
|
+
for r in range(k):
|
|
310
|
+
# Indices of objectives other than r
|
|
311
|
+
other = np.delete(np.arange(k), r)
|
|
312
|
+
|
|
313
|
+
# Find points that are no worse than z_intermediate in all objectives except r
|
|
314
|
+
mask = np.all(non_dominated_points[:, other] <= z_intermediate[other], axis=1)
|
|
315
|
+
feasible = non_dominated_points[mask]
|
|
316
|
+
|
|
317
|
+
if feasible.size > 0:
|
|
318
|
+
bounds.append(np.min(feasible[:, r]))
|
|
319
|
+
else:
|
|
320
|
+
bounds.append(np.inf) # No feasible point in this projection
|
|
321
|
+
|
|
322
|
+
return np.array(bounds)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def calculate_closeness(z_intermediate: np.ndarray, z_nadir: np.ndarray, z_representative: np.ndarray) -> float:
|
|
326
|
+
"""Calculate the closeness of an intermediate point to the non-dominated set.
|
|
327
|
+
|
|
328
|
+
The greater the closeness is, the close intermediate point is to the non-dominated set.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
z_intermediate (np.ndarray): the intermediate point.
|
|
332
|
+
z_nadir (np.ndarray): the nadir point of the non-dominated set.
|
|
333
|
+
z_representative (np.ndarray): the representative solution of `z_intermediate`.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
float: the closeness measure.
|
|
337
|
+
"""
|
|
338
|
+
return np.linalg.norm(z_intermediate - z_nadir) / np.linalg.norm(z_representative - z_nadir) * 100
|