Source code for moptipy.mo.problem.basic_mo_problem

"""The base class for implementing multi-objective problems."""
from math import inf, isfinite
from typing import Any, Callable, Final, Iterable

import numpy as np
from numpy import empty
from pycommons.types import type_error

from moptipy.api.logging import KEY_SPACE_NUM_VARS, SCOPE_OBJECTIVE_FUNCTION
from moptipy.api.mo_problem import MOProblem
from moptipy.api.mo_utils import dominates
from moptipy.api.objective import Objective
from moptipy.utils.logger import KeyValueLogSection
from moptipy.utils.math import try_int
from moptipy.utils.nputils import (
    KEY_NUMPY_TYPE,
    dtype_for_data,
    numpy_type_to_str,
)


[docs] class BasicMOProblem(MOProblem): """ The base class for implementing multi-objective optimization problems. This class allows to construct a simple python function for scalarizing a vector of objective values in its constructor and also determines the right datatype for the objective vectors. It therefore first obtains the type (integers or floats?) of the objective values as well as the bounds of the objective functions. This is used to determine the right numpy `dtype` for the objective vectors. We want to represent objective vectors as compact as possible and use an integer vector if possible. Once this information is obtained, we invoke a call-back function `get_scalarizer` which should return a python function that computes the scalarization result, i.e., the single scalar value representing the vector of objective values in single-objective optimization. This function must be monotonous. If the bounds are finite, it is applied to the vector of lower and upper bounds to get the lower and upper bounds of the scalarization result. Examples for implementing this class are class:`~moptipy.mo.problem.weighted_sum.WeightedSum` and :class:`~moptipy.mo.problem.weighted_sum.Prioritize`, which represent a multi-objective optimization problem either as weighted sum or by priorizing the objective value (via an internal weighted sum). """ def __init__(self, objectives: Iterable[Objective], get_scalarizer: Callable[[bool, int, list[int | float], list[int | float]], Callable[[np.ndarray], int | float]] | None = None, domination: Callable[[np.ndarray, np.ndarray], int] | None = dominates) -> None: """ Create the basic multi-objective optimization problem. :param objectives: the objective functions :param get_scalarizer: Create the function for scalarizing the objective values. This constructor receives as parameters a `bool` which is `True` if and only if all objective functions always return integers and `False` otherwise, i.e., if at least one of them may return a `float`, the length of the f-vectors, and lists with the lower and upper bounds of the objective functions. It can use this information to dynamically create and return the most efficient scalarization function. :param domination: a function reflecting the domination relationship between two vectors of objective values. It must obey the contract of :meth:`~moptipy.api.mo_problem.MOProblem.f_dominates`, which is the same as :func:`moptipy.api.mo_utils.dominates`, to which it defaults. `None` overrides nothing. """ if not isinstance(objectives, Iterable): raise type_error(objectives, "objectives", Iterable) if not callable(get_scalarizer): raise type_error(get_scalarizer, "get_scalarizer", call=True) lower_bounds: Final[list[int | float]] = [] upper_bounds: Final[list[int | float]] = [] calls: Final[list[Callable[[Any], int | float]]] = [] min_lower_bound: int | float = inf max_upper_bound: int | float = -inf # Iterate over all objective functions and see whether they are # integer-valued and have finite bounds and to collect the bounds. always_int: bool = True is_int: bool lb: int | float ub: int | float for objective in objectives: if not isinstance(objective, Objective): raise type_error(objective, "objective[i]", Objective) is_int = objective.is_always_integer() always_int = always_int and is_int calls.append(objective.evaluate) lb = objective.lower_bound() if isfinite(lb): if is_int: if not isinstance(lb, int): raise ValueError( f"if is_always_integer() of objective {objective}" " is True, then lower_bound() must be infinite or" f" int, but is {lb}.") else: lb = try_int(lb) min_lower_bound = min(min_lower_bound, lb) else: min_lower_bound = -inf lower_bounds.append(lb) ub = objective.upper_bound() if isfinite(ub): if is_int: if not isinstance(ub, int): raise ValueError( f"if is_always_integer() of objective {objective}" " is True, then upper_bound() must be infinite " f"or int, but is {ub}.") else: ub = try_int(ub) max_upper_bound = max(max_upper_bound, ub) else: max_upper_bound = inf if lb >= ub: raise ValueError( f"lower_bound()={lb} of objective {objective} must " f"be < than upper_bound()={ub}") upper_bounds.append(ub) n: Final[int] = len(calls) if n <= 0: raise ValueError("No objective function found!") use_lb: int | float = min_lower_bound use_ub: int | float = max_upper_bound if always_int: if isfinite(min_lower_bound) and isfinite(max_upper_bound): use_lb = min(min_lower_bound, min_lower_bound - max_upper_bound) use_ub = max(max_upper_bound, max_upper_bound - min_lower_bound) else: use_lb = -inf use_ub = inf # Based on the above findings, determine the data type: #: The data type of the objective vectors. #: If the objectives all always are integers and have known and finite #: bounds, then we can use the smallest possible integer type. #: This type will be large enough to allow computing "a - b" of any two #: objective values "a" and "b" without overflow. #: If they are at least integer-valued, we can use the largest integer #: type. #: If also this is not True, then we just use floating points. self.__dtype: Final[np.dtype] = dtype_for_data( always_int, use_lb, use_ub) #: The dimension of the objective space. self.__dimension: Final[int] = n #: the creator function for objective vectors self.f_create = lambda nn=n, dt=self.__dtype: empty( # type: ignore nn, dt) # type: ignore #: the holder for lower bounds self.__lower_bounds: Final[tuple[int | float, ...]] = \ tuple(lower_bounds) #: the holder for upper bounds self.__upper_bounds: Final[tuple[int | float, ...]] = \ tuple(upper_bounds) # set up the scalarizer self._scalarize: Final[Callable[[np.ndarray], int | float]] \ = get_scalarizer(always_int, n, lower_bounds, upper_bounds) if not callable(self._scalarize): raise type_error(self._scalarize, "result of get_scalarizer", call=True) # compute the scalarized bounds temp: np.ndarray | None = None lb = -inf if isfinite(min_lower_bound): temp = np.array(lower_bounds, dtype=self.__dtype) lb = self._scalarize(temp) if not isinstance(lb, int | float): raise type_error(lb, "computed lower bound", (int, float)) if (not isfinite(lb)) and (lb > -inf): raise ValueError("non-finite computed lower bound " f"can only be -inf, but is {lb}.") lb = try_int(lb) #: the lower bound of this scalarization self.__lower_bound: Final[int | float] = lb ub = inf if isfinite(max_upper_bound): temp = np.array(upper_bounds, dtype=self.__dtype) ub = self._scalarize(temp) if not isinstance(ub, int | float): raise type_error(ub, "computed upper bound", (int, float)) if (not isfinite(ub)) and (ub < inf): raise ValueError("non-finite computed upper bound " f"can only be inf, but is {ub}.") ub = try_int(ub) #: the upper bound of this scalarization self.__upper_bound: Final[int | float] = ub #: the internal objectives self.__calls: Final[tuple[ Callable[[Any], int | float], ...]] = tuple(calls) #: the objective functions self._objectives = tuple(objectives) #: the internal temporary array self._temp: Final[np.ndarray] = self.f_create() \ if temp is None else temp if domination is not None: if not callable(domination): raise type_error(domination, "domination", call=True) self.f_dominates = domination # type: ignore
[docs] def initialize(self) -> None: """Initialize the multi-objective problem.""" super().initialize() for ff in self._objectives: ff.initialize()
[docs] def f_dimension(self) -> int: """ Obtain the number of objective functions. :returns: the number of objective functions """ return self.__dimension
[docs] def f_dtype(self) -> np.dtype: """ Get the data type used in `f_create`. :returns: the data type used by :meth:`moptipy.api.mo_problem.MOProblem.f_create`. """ return self.__dtype
[docs] def f_evaluate(self, x, fs: np.ndarray) -> int | float: """ Perform the multi-objective evaluation of a solution. :param x: the solution to be evaluated :param fs: the array to receive the objective values :returns: the scalarized objective values """ for i, o in enumerate(self.__calls): fs[i] = o(x) return self._scalarize(fs)
[docs] def lower_bound(self) -> float | int: """ Get the lower bound of the scalarization result. This function returns a theoretical limit for how good a solution could be at best. If no real limit is known, the function returns `-inf`. :return: the lower bound of the scalarization result """ return self.__lower_bound
[docs] def upper_bound(self) -> float | int: """ Get the upper bound of the scalarization result. This function returns a theoretical limit for how bad a solution could be at worst. If no real limit is known, the function returns `inf`. :return: the upper bound of the scalarization result """ return self.__upper_bound
[docs] def evaluate(self, x) -> float | int: """ Convert the multi-objective problem into a single-objective one. This function first evaluates all encapsulated objectives and then scalarizes the result. :param x: the candidate solution :returns: the scalarized objective value """ return self.f_evaluate(x, self._temp)
def __str__(self) -> str: """Get the string representation of this basic scalarization.""" return "basicMoProblem"
[docs] def log_parameters_to(self, logger: KeyValueLogSection) -> None: """ Log the parameters of this function to the provided destination. :param logger: the logger for the parameters """ super().log_parameters_to(logger) logger.key_value(KEY_SPACE_NUM_VARS, self.__dimension) logger.key_value(KEY_NUMPY_TYPE, numpy_type_to_str(self.__dtype)) for i, o in enumerate(self._objectives): with logger.scope(f"{SCOPE_OBJECTIVE_FUNCTION}{i}") as scope: o.log_parameters_to(scope)
[docs] def validate(self, x: np.ndarray) -> None: """ Validate an objective vector. :param x: the objective vector :raises TypeError: if the string is not an element of this space. :raises ValueError: if the shape of the vector is wrong or any of its element is not finite. """ super().f_validate(x) lb: Final[tuple[int | float, ...]] = self.__lower_bounds ub: Final[tuple[int | float, ...]] = self.__upper_bounds for i, v in enumerate(x): if v < lb[i]: raise ValueError( f"encountered {v} at index {i} of {x}, which is below the " f"lower bound {lb[i]} for that position.") if v > ub[i]: raise ValueError( f"encountered {v} at index {i} of {x}, which is above the " f"upper bound {ub[i]} for that position.")