"""
The base classes for multi-objective optimization problems.
This class provides the ability to evaluate solutions according to multiple
criteria. The evaluation results are stored in a numpy array and also are
scalarized to a single value.
Basically, a multi-objective problem provides three essential components:
1. It can evaluate a candidate solution according to multiple optimization
objectives. Each objective returns one value, subject to minimization,
and all the values are stored in a single numpy array.
This is done by :meth:`~moptipy.api.mo_problem.MOProblem.f_evaluate`
2. It provides a criterion deciding whether one such objective vector
dominates (i.e., is strictly better than) another one. This is done by
:meth:`~moptipy.api.mo_problem.MOProblem.f_dominates`. The default
definition adheres to the standard "domination" definition in
multi-objective optimization: A vector `a` dominates a vector `b` if it
is not worse in any objective value and better in at least one. But if
need be, you can overwrite this behavior.
3. A scalarization approach: When evaluating a solution, the result is not
just the objective vector itself, but also a single scalar value. This is
needed to create compatibility to single-objective optimization. Matter of
fact, a :class:`~moptipy.api.mo_problem.MOProblem` is actually a subclass
of :class:`~moptipy.api.objective.Objective`. This means that via this
scalarization, all multi-objective problems can also be considered as
single-objective problems. This means that single-objective algorithms can
be applied to them as-is. It also means that log files are compatible.
Multi-objective algorithms can just ignore the scalarization result and
focus on the domination relationship. Often, a weighted sum approach
(:class:`~moptipy.mo.problem.weighted_sum.WeightedSum`) may be the method
of choice for scalarization.
"""
from typing import Any, Final
import numpy as np
from pycommons.types import type_error
from moptipy.api.logging import KEY_SPACE_NUM_VARS, SCOPE_OBJECTIVE_FUNCTION
from moptipy.api.mo_utils import dominates
from moptipy.api.objective import Objective, check_objective
from moptipy.utils.logger import KeyValueLogSection
from moptipy.utils.nputils import (
DEFAULT_FLOAT,
DEFAULT_INT,
DEFAULT_UNSIGNED_INT,
KEY_NUMPY_TYPE,
int_range_to_dtype,
numpy_type_to_str,
)
[docs]
class MOProblem(Objective):
"""
The base class for multi-objective optimization problems.
A multi-objective optimization problem is defined as a set of
:class:`~moptipy.api.objective.Objective` functions. Each candidate
solution is evaluated using each of the objectives, i.e., is rated by a
vector of objective values. This vector is the basis for deciding which
candidate solutions to keep and which to discard.
In multi-objective optimization, this decision is based on "domination."
A solution `a` dominates a solution `b` if it is not worse in any
objective and better in at least one. This comparison behavior is
implemented in method
:meth:`~moptipy.api.mo_problem.MOProblem.f_dominates` and can be
overwritten if need be.
In our implementation, we prescribe that each multi-objective optimization
problem must also be accompanied by a scalarization function, i.e., a
function that represents the vector of objective values as a single scalar
value. The whole multi-objective problem can then be viewed also as a
single objective function itself. The method
:meth:`~moptipy.api.mo_problem.MOProblem.evaluate` first evaluates all of
the objective functions and obtains the vector of objective values. It then
scalarizes the result into a single scalar quality and returns it.
Multi-objective algorithms may instead use the method
:meth:`~moptipy.api.mo_problem.MOProblem.f_evaluate`, which also allows
a vector to be passed in which will then be filled with the results of the
individual objective functions.
This makes multi-objective optimization with moptipy compatible with
single-objective optimization. In other words, all optimization methods
implemented for single-objective processes
:class:`~moptipy.api.process.Process` will work out-of-the-box with the
multi-objective version :class:`~moptipy.api.mo_process.MOProcess`.
Warning: We use instances of :class:`numpy.ndarray` to represent the
vectors of objective values. This necessitates that each objective
function has, if it is integer-valued
(:meth:`~moptipy.api.objective.Objective.is_always_integer` is `True`)
a range that fits well into at least a 64-bit integer. Specifically, it
must be possible to compute "a - b" without overflow or loss of sign for
any two objective values "a" and "b" within the confines of a numpy
signed 64-bit integer.
"""
[docs]
def f_create(self) -> np.ndarray:
"""
Create a vector to receive the objective values.
This array will be of the length returned by :meth:`f_dimension` and
of the `dtype` of :meth:`f_dtype`.
:returns: a vector to receive the objective values
"""
return np.empty(self.f_dimension(), self.f_dtype())
[docs]
def f_dtype(self) -> np.dtype:
"""
Get the data type used in :meth:`f_create`.
This data type will be an integer data type if all the objective
functions are integer-valued. If the bounds of the objective values
are known, then this type will be "big enough" to allow the
subtraction "a - b" of any two objective vectors "a" and "b" to be
computed without overflow or loss of sign. At most, however, this
data type will be a 64-bit integer.
If any one of the objective functions returns floating point data,
this data type will be a floating point type.
:returns: the data type used by :meth:`f_create`.
"""
[docs]
def f_dimension(self) -> int:
"""
Obtain the number of objective functions.
:returns: the number of objective functions
"""
[docs]
def f_validate(self, x: np.ndarray) -> None:
"""
Validate the objective vector.
:param x: the numpy 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.
"""
if not isinstance(x, np.ndarray):
raise TypeError(x, "x", np.ndarray)
shape = x.shape
if len(shape) != 1:
raise ValueError(
f"{x} cannot have more than one dimension, but has {shape}!")
dim = self.f_dimension() # pylint: disable=E1111
if shape[0] != dim:
raise ValueError(
f"{x} should have length {dim} but has {shape[0]}!")
dt = self.f_dtype() # pylint: disable=E1111
if x.dtype != dt:
raise ValueError(f"{x} should have dtype {dt} but has {x.dtype}!")
[docs]
def f_evaluate(self, x, fs: np.ndarray) -> int | float:
"""
Perform the multi-objective evaluation of a solution.
This method fills the objective vector `fs` with the results of the
objective functions evaluated on `x`. It then returns the scalarized
result, i.e., a single scalar value computed based on all values
in `fs`.
:param x: the solution to be evaluated
:param fs: the array to receive the objective values
:returns: the scalarization result
"""
# noinspection PyMethodMayBeStatic
[docs]
def f_dominates(self, a: np.ndarray, b: np.ndarray) -> int:
"""
Check if an objective vector dominates or is dominated by another one.
Usually, one vector is said to dominate another one if it is not worse
in any objective and better in at least one. This behavior is
implemented in :func:`moptipy.api.mo_utils.dominates` and this is also
the default behavior of this method. However, depending on your
concrete optimization task, you may overwrite this behavior.
:param a: the first objective vector
:param b: the second objective value
:returns: an integer value indicating the domination relationship
:retval -1: if `a` dominates `b`
:retval 1: if `b` dominates `a`
:retval 2: if `b` equals `a`
:retval 0: if `a` and `b` are mutually non-dominated, i.e., if neither
`a` dominates `b` not `b` dominates `a` and `b` is also different
from `a`
"""
return dominates(a, b)
[docs]
def evaluate(self, x) -> float | int:
"""
Evaluate a solution `x` and return its scalarized objective value.
This method computes all objective values for a given solution and
then returns the scalarized result. The objective values themselves
are directly discarted and not used. It makes a multi-objective
problem compatible with single-objective optimization.
:param x: the candidate solution
:returns: the scalarized objective value
"""
return self.f_evaluate(x, self.f_create())
def __str__(self) -> str:
"""
Get the string representation of this multi-objective problem.
:returns: the string representation of this multi-objective problem
"""
return "moProblem"
[docs]
def check_mo_problem(mo_problem: Any) -> MOProblem:
"""
Check whether an object is a valid instance of :class:`MOProblem`.
:param mo_problem: the multi-objective optimization problem
:return: the mo-problem
:raises TypeError: if `mo_problem` is not an instance of
:class:`MOProblem`
>>> check_mo_problem(MOProblem())
moProblem
>>> try:
... check_mo_problem(1)
... except TypeError as te:
... print(te)
multi-objective optimziation problem should be an instance of moptipy.\
api.mo_problem.MOProblem but is int, namely '1'.
>>> try:
... check_mo_problem(None)
... except TypeError as te:
... print(te)
multi-objective optimziation problem should be an instance of moptipy.\
api.mo_problem.MOProblem but is None.
"""
if isinstance(mo_problem, MOProblem):
return mo_problem
raise type_error(mo_problem,
"multi-objective optimziation problem", MOProblem)
[docs]
class MOSOProblemBridge(MOProblem):
"""A bridge between multi-objective and single-objective optimization."""
def __init__(self, objective: Objective) -> None:
"""Initialize the bridge."""
super().__init__()
check_objective(objective)
self.evaluate = objective.evaluate # type: ignore
self.lower_bound = objective.lower_bound # type: ignore
self.upper_bound = objective.upper_bound # type: ignore
self.is_always_integer = objective.is_always_integer # type: ignore
dt: np.dtype
if self.is_always_integer():
lb: int | float = self.lower_bound()
ub: int | float = self.upper_bound()
dt = DEFAULT_INT
if isinstance(lb, int):
if isinstance(ub, int):
dt = int_range_to_dtype(lb, ub)
elif lb >= 0:
dt = DEFAULT_UNSIGNED_INT
else:
dt = DEFAULT_FLOAT
#: the data type of the objective array
self.__dtype: Final[np.dtype] = dt
#: the objective function
self.__f: Final[Objective] = objective
self.f_create = lambda dd=dt: np.empty(1, dd) # type: ignore
self.f_dimension = lambda: 1 # type: ignore
[docs]
def initialize(self) -> None:
"""Initialize the MO-problem bridge."""
super().initialize()
self.__f.initialize()
[docs]
def f_evaluate(self, x, fs: np.ndarray) -> int | float:
"""
Evaluate the candidate solution.
:param x: the solution
:param fs: the objective vector, will become `[res]`
:returns: the objective value `res`
"""
res: Final[int | float] = self.evaluate(x)
fs[0] = res
return res
[docs]
def f_dtype(self) -> np.dtype:
"""Get the objective vector dtype."""
return self.__dtype
[docs]
def f_validate(self, x: np.ndarray) -> None:
"""
Validate the objective vector.
:param x: the numpy array with the objective values
"""
if not isinstance(x, np.ndarray):
raise type_error(x, "x", np.ndarray)
if len(x) != 1:
raise ValueError(f"length of x={len(x)}")
lb = self.lower_bound()
ub = self.upper_bound()
if not (lb <= x[0] <= ub):
raise ValueError(f"failed: {lb} <= {x[0]} <= {ub}")
[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, "1")
logger.key_value(KEY_NUMPY_TYPE, numpy_type_to_str(self.__dtype))
with logger.scope(f"{SCOPE_OBJECTIVE_FUNCTION}{0}") as scope:
self.__f.log_parameters_to(scope)