Source code for physbo.test_functions.base

# SPDX-License-Identifier: MPL-2.0
# Copyright (C) 2020- The University of Tokyo
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

from __future__ import annotations

from abc import ABC, abstractmethod
import copy

import numpy as np

from ..search import utility as search_utility


[docs] class TestFunction(ABC): """Abstract class for test functions. Test functions are used to evaluate the performance of the optimization algorithms. Note ===== Each test function is implemented in the same sense (minimization or maximization) as in the reference it follows, so that ``f`` and the docstring can be compared with the literature directly. A subclass declares its sense by the class attribute ``_is_maximization`` (``False`` by default, i.e., minimization). ``test_maximizer`` selects the sense of the *returned* values: with ``test_maximizer=True`` (the default) the values returned by ``__call__`` and the reference box always describe a maximization problem (as PHYSBO maximizes objectives), and with ``test_maximizer=False`` they always describe a minimization problem. The sign is flipped only when the declared sense and the requested sense differ. Arguments ========= nobj: int Number of objectives. dim: int Number of dimensions. min_X: np.ndarray | list[float] | float Minimum value of search space for each dimension. max_X: np.ndarray | list[float] | float Maximum value of search space for each dimension. test_maximizer: bool, default=True If True, the returned values describe a maximization problem (for testing a maximization problem solver such as PHYSBO). If False, they describe a minimization problem. name: str | None, default=None Name of the test function. If None, the class name is used. """ # Sense in which ``f`` (and the reference box) is written. # Subclasses whose reference defines a maximization problem set this to True. _is_maximization: bool = False def __init__( self, nobj: int, dim: int, min_X: np.ndarray | list[float] | float, max_X: np.ndarray | list[float] | float, test_maximizer: bool = True, name: str | None = None, ): self._nobj = nobj self._dim = dim self._test_maximizer = test_maximizer self._name = name if isinstance(min_X, float): self._min_X = np.full(dim, min_X) elif isinstance(min_X, list): self._min_X = np.array(min_X) else: self._min_X = copy.deepcopy(min_X) if isinstance(max_X, float): self._max_X = np.full(dim, max_X) elif isinstance(max_X, list): self._max_X = np.array(max_X) else: self._max_X = copy.deepcopy(max_X) if self._min_X.shape[0] != self._dim: raise ValueError( f"ERROR: dimension mismatch: self._min_X.shape[0] = {self._min_X.shape[0]}, self._dim = {self._dim}" ) if self._max_X.shape[0] != self._dim: raise ValueError( f"ERROR: dimension mismatch: self._max_X.shape[0] = {self._max_X.shape[0]}, self._dim = {self._dim}" ) def __call__(self, x: np.ndarray) -> np.ndarray: """Evaluate the test function at the given point. Arguments ========= x: np.ndarray The point at which to evaluate the test function. x is a numpy array of shape (n, d), where n is the number of points and d is the dimension of the input space. Returns ======= f: np.ndarray The value of the test function at the given point. The output value is a numpy array of shape (n, k), where k is the number of objectives. """ if x.shape[1] != self._dim: raise ValueError( f"ERROR: dimension mismatch: x.shape[1] = {x.shape[1]}, self._dim = {self._dim}" ) f = self.f(x) # This is assertion because it is the Developer's responsibility to ensure that the number of objectives is correct assert f.shape[1] == self._nobj if self._needs_negation(): return -f else: return f def _needs_negation(self) -> bool: """Whether the values of ``f`` must be negated to obtain the requested sense. The sign is flipped only when the sense in which ``f`` is written (``_is_maximization``) differs from the requested sense (``test_maximizer``). """ return self._test_maximizer != self._is_maximization @property def is_maximization(self) -> bool: """Whether the test function is defined as a maximization problem in its reference. This describes how ``f`` is written, not the sense of the returned values (which is selected by ``test_maximizer``). Returns ======= bool True if the original problem is a maximization problem. """ return self._is_maximization @property def test_maximizer(self) -> bool: """Whether the returned values describe a maximization problem. Returns ======= bool True if ``__call__`` returns values of a maximization problem. """ return self._test_maximizer @property def dim(self) -> int: """Get the number of dimensions of the test function. Returns ======= int The number of dimensions of the test function d. """ return self._dim @property def nobj(self) -> int: """Get the number of objectives of the test function. Returns ======= int The number of objectives of the test function k. """ return self._nobj @property def min_X(self) -> np.ndarray: """Get the minimum values of the search space of the test function. Returns ======= np.ndarray The minimum value of the test function for each dimension. """ return copy.deepcopy(self._min_X) @property def max_X(self) -> np.ndarray: """Get the maximum values of the search space of the test function. Returns ======= np.ndarray The maximum value of the test function for each dimension. """ return copy.deepcopy(self._max_X)
[docs] @abstractmethod def f(self, x: np.ndarray) -> np.ndarray: """Evaluate the test function at the given point. ``f`` is written in the sense of the reference (see ``_is_maximization``); the conversion to the requested sense is done by ``__call__``. Arguments ========= x: np.ndarray The point at which to evaluate the test function. x is a numpy array of shape (n, d), where n is the number of points and d is the dimension of the input space. Returns ======= f: np.ndarray The value of the test function at the given point. The output value is a numpy array of shape (n, k), where k is the number of objectives. """ ...
[docs] def constraint(self, x: np.ndarray) -> np.ndarray: """Evaluate the constraint function at the given point. Arguments ========= x: np.ndarray The point at which to evaluate the constraint function. x is a numpy array of shape (n, d), where n is the number of points and d is the dimension of the input space. Returns ======= np.ndarray The boolean values indicating whether the point is valid or not. The output value is a numpy array of shape (n,), where n is the number of points. """ # default implementation is that all points are valid return np.ones(x.shape[0], dtype=bool)
[docs] def make_grid(self, num_X: int | list[int] | np.ndarray) -> np.ndarray: """Make a grid of points in the search space. Arguments ========= num_X: int | list[int] | np.ndarray Number of points in each dimension. Returns ======= np.ndarray The grid of points in the search space. The output is a numpy array of shape (N, d), where N is the number of points and d is the dimension of the search space. """ return search_utility.make_grid(self.min_X, self.max_X, num_X, constraint=self.constraint)
[docs] def set_name(self, name: str): """Set the name of the test function. Arguments ========= name: str The name of the test function. """ self._name = name
@property def name(self) -> str: """Get the name of the test function. Returns ======= str The name of the test function. """ if self._name is None: return self.__class__.__name__ else: return self._name