Coverage for moptipy / mo / problem / weighted_sum.py: 80%
128 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-24 08:49 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-24 08:49 +0000
1"""
2The weighted sum scalarization of multi-objective problems.
4Here we provide two simple methods to scalarize multi-objective problems by
5using weights, namely
7- :class:`~moptipy.mo.problem.weighted_sum.WeightedSum`, a sum with arbitrary,
8 user-defined weights of the objective values
9- :class:`~moptipy.mo.problem.weighted_sum.Prioritize`, a weighted sum of the
10 objective values where the weights are automatically determined such that
11 the first objective function is prioritized over the second one, the second
12 one over the third, and so on.
13"""
14from math import inf, isfinite
15from typing import Any, Callable, Final, Iterable, cast
17import numpy as np
18from numpy import sum as npsum
19from pycommons.strings.string_conv import num_to_str
20from pycommons.types import type_error
22from moptipy.api.mo_utils import dominates
23from moptipy.api.objective import Objective
24from moptipy.mo.problem.basic_mo_problem import BasicMOProblem
25from moptipy.utils.logger import KeyValueLogSection
26from moptipy.utils.math import try_int
27from moptipy.utils.nputils import dtype_for_data
30def _sum_int(a: np.ndarray) -> int:
31 """
32 Sum up an array and convert the result to an `int` value.
34 :param a: the array
35 :returns: the sum of the elements in `a` as `int`
36 """
37 return int(npsum(a))
40def _sum_float(a: np.ndarray) -> float:
41 """
42 Sum up an array and convert the result to a `float` value.
44 :param a: the array
45 :returns: the sum of the elements in `a` as `float`
46 """
47 return float(npsum(a))
50class BasicWeightedSum(BasicMOProblem):
51 """
52 Base class for scalarizing objective values by a weighted sum.
54 This class brings the basic tools to scalarize vectors of objective
55 values by computing weighted sums. This class should not be used
56 directly. Instead, use its sub-classes
57 :class:`~moptipy.mo.problem.weighted_sum.WeightedSum` and
58 :class:`~moptipy.mo.problem.weighted_sum.Prioritize`.
59 """
61 def __init__(self, objectives: Iterable[Objective],
62 get_scalarizer: Callable[
63 [bool, int, list[int | float],
64 list[int | float], Callable[
65 [np.dtype | tuple[int | float, ...] | None], None]],
66 Callable[[np.ndarray], int | float]],
67 domination: Callable[[np.ndarray, np.ndarray], int] | None
68 = dominates) -> None:
69 """
70 Create the sum-based scalarization.
72 :param objectives: the objectives
73 :param domination: a function reflecting the domination relationship
74 between two vectors of objective values. It must obey the contract
75 of :meth:`~moptipy.api.mo_problem.MOProblem.f_dominates`, which is
76 the same as :func:`moptipy.api.mo_utils.dominates`, to which it
77 defaults. `None` overrides nothing.
78 """
79 holder: list[Any] = []
80 super().__init__(
81 objectives,
82 cast("Callable[[bool, int, list[int | float], list[int | float]],"
83 "Callable[[np.ndarray], int | float]]",
84 lambda ai, n, lb, ub, fwd=holder.append:
85 get_scalarizer(ai, n, lb, ub, fwd)),
86 domination)
87 if len(holder) != 2:
88 raise ValueError(
89 f"need weights and weights dtype, but got {holder}.")
90 #: the internal weights
91 self.weights: Final[tuple[int | float, ...] | None] = \
92 cast("tuple[int | float, ...] | None", holder[0])
93 if self.weights is not None:
94 if not isinstance(self.weights, tuple):
95 raise type_error(self.weights, "weights", [tuple, None])
96 if len(self.weights) != self.f_dimension():
97 raise ValueError(
98 f"length of weights {self.weights} is not "
99 f"f_dimension={self.f_dimension()}.")
100 #: the internal weights dtype
101 self.__weights_dtype: Final[np.dtype | None] = \
102 cast("np.dtype | None", holder[1])
103 if (self.__weights_dtype is not None) \
104 and (not isinstance(self.__weights_dtype, np.dtype)):
105 raise type_error(
106 self.__weights_dtype, "weights_dtype", np.dtype)
108 def __str__(self):
109 """
110 Get the string representation of the weighted sum scalarization.
112 :returns: `"weightedSumBase"`
113 """
114 return "weightedSumBase"
116 def log_parameters_to(self, logger: KeyValueLogSection) -> None:
117 """
118 Log the parameters of this function to the provided destination.
120 :param logger: the logger for the parameters
121 """
122 super().log_parameters_to(logger)
124 weights: tuple[int | float, ...] | None = self.weights
125 logger.key_value("weights", ";".join(
126 (["1"] * self.f_dimension()) if weights is None else
127 [num_to_str(w) for w in weights]))
128 logger.key_value("weightsDtype",
129 "None" if self.__weights_dtype is None
130 else self.__weights_dtype.char)
133def _make_sum_scalarizer(
134 always_int: bool, n: int,
135 lower_bounds: list[int | float], upper_bounds: list[int | float],
136 weights: tuple[int | float, ...] | None,
137 callback: Callable[
138 [np.dtype | tuple[int | float, ...] | None],
139 None]) -> Callable[[np.ndarray], int | float]:
140 """
141 Create a weighted sum scalarization function.
143 If `weights` is `None`, we will just use the plain summation function from
144 numpy and convert its result to `int` if `always_int` is `True` and to
145 `float` otherwise.
146 If `weights` is a tuple of weights, then we will convert it to a numpy
147 `ndarray`. If `always_int` is `True` and all weights are integers, and the
148 lower and upper bound of the objectives are known, we try to pick the
149 smallest integer data type for this array big enough to hold both the
150 total lower and total upper bound. If at least the lower bounds are >= 0,
151 then we pick the largest unsigned integer data type. If the bounds are
152 unknown, then we pick the largest signed integer type. If any weight is
153 not an integer and `always_int` is `False`, we use a default floating
154 point weight array.
156 This should yield the overall fastest, most precise, and most memory
157 efficient way to compute a weighted sum scalarization.
159 :param always_int: will all objectives always be integer
160 :param n: the number of objectives
161 :param lower_bounds: the optional lower bounds
162 :param upper_bounds: the optional upper bounds
163 :param weights: the optional array of weights, `None` if all weights
164 are `1`.
165 :param callback: the callback function to receive the weights
166 :returns: the scalarization function
167 """
168 if not isinstance(lower_bounds, list):
169 raise type_error(lower_bounds, "lower_bounds", list)
170 if len(lower_bounds) != n:
171 raise ValueError(
172 f"there should be {n} values in lower_bounds={lower_bounds}")
174 if not isinstance(upper_bounds, list):
175 raise type_error(upper_bounds, "upper_bounds", list)
176 if len(upper_bounds) != n:
177 raise ValueError(
178 f"there should be {n} values in upper_bounds={lower_bounds}")
180 if weights is None:
181 callback(None)
182 callback(None)
183 return _sum_int if always_int else _sum_float
184 if not isinstance(weights, tuple):
185 raise type_error(weights, "weights", tuple)
186 if len(weights) != n:
187 raise ValueError(
188 f"there should be {n} values in weights={lower_bounds}")
189 if not isinstance(always_int, bool):
190 raise type_error(always_int, "always_int", bool)
192 min_sum: int | float = 0
193 max_sum: int | float = 0
194 min_weight: int | float = inf
195 max_weight: int | float = -inf
196 everything_is_int: bool = always_int
198 for i, weight in enumerate(weights):
199 if weight <= 0:
200 raise ValueError("no weight can be <=0, but encountered "
201 f"{weight} in {weights}.")
203 if not isinstance(weight, int):
204 everything_is_int = False
205 if not isfinite(weight):
206 raise ValueError("weight must be finite, but "
207 f"encountered {weight} in {weights}.")
208 min_sum = -inf
209 max_sum = inf
210 break
212 min_weight = min(min_weight, weight)
213 max_weight = max(max_weight, weight)
215 if lower_bounds is not None:
216 min_sum += weight * lower_bounds[i]
217 if upper_bounds is not None:
218 max_sum += weight * upper_bounds[i]
220 if min_sum >= max_sum:
221 raise ValueError(
222 f"weighted sum minimum={min_sum} >= maximum={max_sum}?")
224 # re-check for plain summation
225 if 1 <= min_weight <= max_weight <= 1:
226 callback(None)
227 callback(None)
228 return _sum_int if always_int else _sum_float
230 dtype: Final[np.dtype] = dtype_for_data(
231 everything_is_int, min_sum, max_sum)
232 use_weights: Final[np.ndarray] = np.array(weights, dtype)
234 callback(weights)
235 callback(dtype)
237 if everything_is_int:
238 return cast("Callable[[np.ndarray], int | float]",
239 lambda a, w=use_weights: int(npsum(a * w)))
240 return cast("Callable[[np.ndarray], int | float]",
241 lambda a, w=use_weights: float(npsum(a * w)))
244class WeightedSum(BasicWeightedSum):
245 """Scalarize objective values by computing their weighted sum."""
247 def __init__(self, objectives: Iterable[Objective],
248 weights: Iterable[int | float] | None = None,
249 domination: Callable[[np.ndarray, np.ndarray], int] | None
250 = dominates) -> None:
251 """
252 Create the sum-based scalarization.
254 :param objectives: the objectives
255 :param weights: the weights of the objective values, or `None` if all
256 weights are `1`.
257 :param domination: a function reflecting the domination relationship
258 between two vectors of objective values. It must obey the contract
259 of :meth:`~moptipy.api.mo_problem.MOProblem.f_dominates`, which is
260 the same as :func:`moptipy.api.mo_utils.dominates`, to which it
261 defaults. `None` overrides nothing.
262 """
263 use_weights: tuple[int | float, ...] | None \
264 = None if weights is None else tuple(try_int(w) for w in weights)
266 super().__init__(
267 objectives,
268 cast("Callable[[bool, int, list[int | float], list[int | float], "
269 "Callable[[np.dtype | tuple[int | float, ...] | None], "
270 "None]], Callable[[np.ndarray], int | float]]",
271 lambda ai, n, lb, ub, cb, uw=use_weights:
272 _make_sum_scalarizer(ai, n, lb, ub, uw, cb)),
273 domination)
275 def __str__(self):
276 """
277 Get the string representation of the weighted sum scalarization.
279 :returns: `"weightedSum"`
280 """
281 return "weightedSum" if self.f_dominates is dominates \
282 else "weightedSumWithDominationFunc"
285def _prioritize(
286 always_int: bool, n: int,
287 lower_bounds: list[int | float], upper_bounds: list[int | float],
288 callback: Callable[[np.dtype | tuple[
289 int | float, ...] | None], None]) \
290 -> Callable[[np.ndarray], int | float]:
291 """
292 Create a weighted-sum based prioritization of the objective functions.
294 If all objective functions are integers and have upper and lower bounds,
295 we can use integer weights to create a prioritization such that gaining
296 one unit of the first objective function is always more important than any
297 improvement of the second objective, that gaining one unit of the second
298 objective always outweighs all possible gains in terms of the third one,
299 and so on.
301 :param always_int: will all objectives always be integer
302 :param n: the number of objectives
303 :param lower_bounds: the optional lower bounds
304 :param upper_bounds: the optional upper bounds
305 :param callback: the callback function to receive the weights
306 :returns: the scalarization function
307 """
308 if n == 1:
309 return _make_sum_scalarizer(always_int, n, lower_bounds,
310 upper_bounds, None, callback)
311 if not always_int:
312 raise ValueError("priority-based weighting is only possible for "
313 "integer-valued objectives")
314 weights: list[int] = [1]
315 weight: int = 1
316 for i in range(n - 1, 0, -1):
317 lb: int | float = lower_bounds[i]
318 if not isinstance(lb, int | float):
319 raise type_error(lb, f"lower_bound[{i}]", (int, float))
320 if not isfinite(lb):
321 raise ValueError(f"lower_bound[{i}]={lb}, but must be finite")
322 if not isinstance(lb, int):
323 raise type_error(lb, f"finite lower_bound[{i}]", int)
324 ub: int | float = upper_bounds[i]
325 if not isinstance(ub, int | float):
326 raise type_error(ub, f"upper_bound[{i}]", (int, float))
327 if not isfinite(ub):
328 raise ValueError(f"upper_bound[{i}]={ub}, but must be finite")
329 if not isinstance(ub, int):
330 raise type_error(ub, f"finite upper_bound[{i}]", int)
331 weight *= (1 + cast("int", ub) - min(0, cast("int", lb)))
332 weights.append(weight)
334 weights.reverse()
335 return _make_sum_scalarizer(always_int, n, lower_bounds, upper_bounds,
336 tuple(weights), callback)
339class Prioritize(BasicWeightedSum):
340 """Prioritize the first objective over the second and so on."""
342 def __init__(self, objectives: Iterable[Objective],
343 domination: Callable[[np.ndarray, np.ndarray], int] | None
344 = dominates) -> None:
345 """
346 Create the sum-based prioritization.
348 :param objectives: the objectives
349 :param domination: a function reflecting the domination relationship
350 between two vectors of objective values. It must obey the contract
351 of :meth:`~moptipy.api.mo_problem.MOProblem.f_dominates`, which is
352 the same as :func:`moptipy.api.mo_utils.dominates`, to which it
353 defaults. `None` overrides nothing.
354 """
355 super().__init__(objectives, _prioritize, domination)
357 def __str__(self):
358 """
359 Get the name of the weighted sum-based prioritization.
361 :returns: `"weightBasedPrioritization"`
362 """
363 return "weightBasedPrioritization" if self.f_dominates is dominates \
364 else "weightBasedPrioritizationWithDominationFunc"