Coverage for moptipy / mo / problem / basic_mo_problem.py: 80%
133 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"""The base class for implementing multi-objective problems."""
2from math import inf, isfinite
3from typing import Any, Callable, Final, Iterable
5import numpy as np
6from numpy import empty
7from pycommons.types import type_error
9from moptipy.api.logging import KEY_SPACE_NUM_VARS, SCOPE_OBJECTIVE_FUNCTION
10from moptipy.api.mo_problem import MOProblem
11from moptipy.api.mo_utils import dominates
12from moptipy.api.objective import Objective
13from moptipy.utils.logger import KeyValueLogSection
14from moptipy.utils.math import try_int
15from moptipy.utils.nputils import (
16 KEY_NUMPY_TYPE,
17 dtype_for_data,
18 numpy_type_to_str,
19)
22class BasicMOProblem(MOProblem):
23 """
24 The base class for implementing multi-objective optimization problems.
26 This class allows to construct a simple python function for scalarizing
27 a vector of objective values in its constructor and also determines the
28 right datatype for the objective vectors.
30 It therefore first obtains the type (integers or floats?) of the objective
31 values as well as the bounds of the objective functions. This is used to
32 determine the right numpy `dtype` for the objective vectors. We want to
33 represent objective vectors as compact as possible and use an integer
34 vector if possible.
36 Once this information is obtained, we invoke a call-back function
37 `get_scalarizer` which should return a python function that computes the
38 scalarization result, i.e., the single scalar value representing the
39 vector of objective values in single-objective optimization. This function
40 must be monotonous. If the bounds are finite, it is applied to the vector
41 of lower and upper bounds to get the lower and upper bounds of the
42 scalarization result.
44 Examples for implementing this class are
45 class:`~moptipy.mo.problem.weighted_sum.WeightedSum` and
46 :class:`~moptipy.mo.problem.weighted_sum.Prioritize`, which represent a
47 multi-objective optimization problem either as weighted sum or by
48 priorizing the objective value (via an internal weighted sum).
49 """
51 def __init__(self, objectives: Iterable[Objective],
52 get_scalarizer: Callable[[bool, int, list[int | float],
53 list[int | float]],
54 Callable[[np.ndarray], int | float]] | None = None,
55 domination: Callable[[np.ndarray, np.ndarray], int] | None
56 = dominates) -> None:
57 """
58 Create the basic multi-objective optimization problem.
60 :param objectives: the objective functions
61 :param get_scalarizer: Create the function for scalarizing the
62 objective values. This constructor receives as parameters a `bool`
63 which is `True` if and only if all objective functions always
64 return integers and `False` otherwise, i.e., if at least one of
65 them may return a `float`, the length of the f-vectors, and lists
66 with the lower and upper bounds of the objective functions. It can
67 use this information to dynamically create and return the most
68 efficient scalarization function.
69 :param domination: a function reflecting the domination relationship
70 between two vectors of objective values. It must obey the contract
71 of :meth:`~moptipy.api.mo_problem.MOProblem.f_dominates`, which is
72 the same as :func:`moptipy.api.mo_utils.dominates`, to which it
73 defaults. `None` overrides nothing.
74 """
75 if not isinstance(objectives, Iterable):
76 raise type_error(objectives, "objectives", Iterable)
77 if not callable(get_scalarizer):
78 raise type_error(get_scalarizer, "get_scalarizer", call=True)
80 lower_bounds: Final[list[int | float]] = []
81 upper_bounds: Final[list[int | float]] = []
82 calls: Final[list[Callable[[Any], int | float]]] = []
83 min_lower_bound: int | float = inf
84 max_upper_bound: int | float = -inf
86 # Iterate over all objective functions and see whether they are
87 # integer-valued and have finite bounds and to collect the bounds.
88 always_int: bool = True
89 is_int: bool
90 lb: int | float
91 ub: int | float
92 for objective in objectives:
93 if not isinstance(objective, Objective):
94 raise type_error(objective, "objective[i]", Objective)
95 is_int = objective.is_always_integer()
96 always_int = always_int and is_int
97 calls.append(objective.evaluate)
98 lb = objective.lower_bound()
99 if isfinite(lb):
100 if is_int:
101 if not isinstance(lb, int):
102 raise ValueError(
103 f"if is_always_integer() of objective {objective}"
104 " is True, then lower_bound() must be infinite or"
105 f" int, but is {lb}.")
106 else:
107 lb = try_int(lb)
108 min_lower_bound = min(min_lower_bound, lb)
109 else:
110 min_lower_bound = -inf
111 lower_bounds.append(lb)
112 ub = objective.upper_bound()
113 if isfinite(ub):
114 if is_int:
115 if not isinstance(ub, int):
116 raise ValueError(
117 f"if is_always_integer() of objective {objective}"
118 " is True, then upper_bound() must be infinite "
119 f"or int, but is {ub}.")
120 else:
121 ub = try_int(ub)
122 max_upper_bound = max(max_upper_bound, ub)
123 else:
124 max_upper_bound = inf
125 if lb >= ub:
126 raise ValueError(
127 f"lower_bound()={lb} of objective {objective} must "
128 f"be < than upper_bound()={ub}")
129 upper_bounds.append(ub)
131 n: Final[int] = len(calls)
132 if n <= 0:
133 raise ValueError("No objective function found!")
135 use_lb: int | float = min_lower_bound
136 use_ub: int | float = max_upper_bound
137 if always_int:
138 if isfinite(min_lower_bound) and isfinite(max_upper_bound):
139 use_lb = min(min_lower_bound,
140 min_lower_bound - max_upper_bound)
141 use_ub = max(max_upper_bound,
142 max_upper_bound - min_lower_bound)
143 else:
144 use_lb = -inf
145 use_ub = inf
147 # Based on the above findings, determine the data type:
148 #: The data type of the objective vectors.
149 #: If the objectives all always are integers and have known and finite
150 #: bounds, then we can use the smallest possible integer type.
151 #: This type will be large enough to allow computing "a - b" of any two
152 #: objective values "a" and "b" without overflow.
153 #: If they are at least integer-valued, we can use the largest integer
154 #: type.
155 #: If also this is not True, then we just use floating points.
156 self.__dtype: Final[np.dtype] = dtype_for_data(
157 always_int, use_lb, use_ub)
158 #: The dimension of the objective space.
159 self.__dimension: Final[int] = n
161 #: the creator function for objective vectors
162 self.f_create = lambda nn=n, dt=self.__dtype: empty( # type: ignore
163 nn, dt) # type: ignore
165 #: the holder for lower bounds
166 self.__lower_bounds: Final[tuple[int | float, ...]] = \
167 tuple(lower_bounds)
168 #: the holder for upper bounds
169 self.__upper_bounds: Final[tuple[int | float, ...]] = \
170 tuple(upper_bounds)
172 # set up the scalarizer
173 self._scalarize: Final[Callable[[np.ndarray], int | float]] \
174 = get_scalarizer(always_int, n, lower_bounds, upper_bounds)
175 if not callable(self._scalarize):
176 raise type_error(self._scalarize, "result of get_scalarizer",
177 call=True)
179 # compute the scalarized bounds
180 temp: np.ndarray | None = None
181 lb = -inf
182 if isfinite(min_lower_bound):
183 temp = np.array(lower_bounds, dtype=self.__dtype)
184 lb = self._scalarize(temp)
185 if not isinstance(lb, int | float):
186 raise type_error(lb, "computed lower bound", (int, float))
187 if (not isfinite(lb)) and (lb > -inf):
188 raise ValueError("non-finite computed lower bound "
189 f"can only be -inf, but is {lb}.")
190 lb = try_int(lb)
191 #: the lower bound of this scalarization
192 self.__lower_bound: Final[int | float] = lb
194 ub = inf
195 if isfinite(max_upper_bound):
196 temp = np.array(upper_bounds, dtype=self.__dtype)
197 ub = self._scalarize(temp)
198 if not isinstance(ub, int | float):
199 raise type_error(ub, "computed upper bound", (int, float))
200 if (not isfinite(ub)) and (ub < inf):
201 raise ValueError("non-finite computed upper bound "
202 f"can only be inf, but is {ub}.")
203 ub = try_int(ub)
204 #: the upper bound of this scalarization
205 self.__upper_bound: Final[int | float] = ub
207 #: the internal objectives
208 self.__calls: Final[tuple[
209 Callable[[Any], int | float], ...]] = tuple(calls)
210 #: the objective functions
211 self._objectives = tuple(objectives)
213 #: the internal temporary array
214 self._temp: Final[np.ndarray] = self.f_create() \
215 if temp is None else temp
217 if domination is not None:
218 if not callable(domination):
219 raise type_error(domination, "domination", call=True)
220 self.f_dominates = domination # type: ignore
222 def initialize(self) -> None:
223 """Initialize the multi-objective problem."""
224 super().initialize()
225 for ff in self._objectives:
226 ff.initialize()
228 def f_dimension(self) -> int:
229 """
230 Obtain the number of objective functions.
232 :returns: the number of objective functions
233 """
234 return self.__dimension
236 def f_dtype(self) -> np.dtype:
237 """
238 Get the data type used in `f_create`.
240 :returns: the data type used by
241 :meth:`moptipy.api.mo_problem.MOProblem.f_create`.
242 """
243 return self.__dtype
245 def f_evaluate(self, x, fs: np.ndarray) -> int | float:
246 """
247 Perform the multi-objective evaluation of a solution.
249 :param x: the solution to be evaluated
250 :param fs: the array to receive the objective values
251 :returns: the scalarized objective values
252 """
253 for i, o in enumerate(self.__calls):
254 fs[i] = o(x)
255 return self._scalarize(fs)
257 def lower_bound(self) -> float | int:
258 """
259 Get the lower bound of the scalarization result.
261 This function returns a theoretical limit for how good a solution
262 could be at best. If no real limit is known, the function returns
263 `-inf`.
265 :return: the lower bound of the scalarization result
266 """
267 return self.__lower_bound
269 def upper_bound(self) -> float | int:
270 """
271 Get the upper bound of the scalarization result.
273 This function returns a theoretical limit for how bad a solution could
274 be at worst. If no real limit is known, the function returns `inf`.
276 :return: the upper bound of the scalarization result
277 """
278 return self.__upper_bound
280 def evaluate(self, x) -> float | int:
281 """
282 Convert the multi-objective problem into a single-objective one.
284 This function first evaluates all encapsulated objectives and then
285 scalarizes the result.
287 :param x: the candidate solution
288 :returns: the scalarized objective value
289 """
290 return self.f_evaluate(x, self._temp)
292 def __str__(self) -> str:
293 """Get the string representation of this basic scalarization."""
294 return "basicMoProblem"
296 def log_parameters_to(self, logger: KeyValueLogSection) -> None:
297 """
298 Log the parameters of this function to the provided destination.
300 :param logger: the logger for the parameters
301 """
302 super().log_parameters_to(logger)
303 logger.key_value(KEY_SPACE_NUM_VARS, self.__dimension)
304 logger.key_value(KEY_NUMPY_TYPE, numpy_type_to_str(self.__dtype))
305 for i, o in enumerate(self._objectives):
306 with logger.scope(f"{SCOPE_OBJECTIVE_FUNCTION}{i}") as scope:
307 o.log_parameters_to(scope)
309 def validate(self, x: np.ndarray) -> None:
310 """
311 Validate an objective vector.
313 :param x: the objective vector
314 :raises TypeError: if the string is not an element of this space.
315 :raises ValueError: if the shape of the vector is wrong or any of its
316 element is not finite.
317 """
318 super().f_validate(x)
320 lb: Final[tuple[int | float, ...]] = self.__lower_bounds
321 ub: Final[tuple[int | float, ...]] = self.__upper_bounds
322 for i, v in enumerate(x):
323 if v < lb[i]:
324 raise ValueError(
325 f"encountered {v} at index {i} of {x}, which is below the "
326 f"lower bound {lb[i]} for that position.")
327 if v > ub[i]:
328 raise ValueError(
329 f"encountered {v} at index {i} of {x}, which is above the "
330 f"upper bound {ub[i]} for that position.")