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