diff --git a/src/evotorch/bbo/__init__.py b/src/evotorch/bbo/__init__.py new file mode 100644 index 00000000..04d95c0c --- /dev/null +++ b/src/evotorch/bbo/__init__.py @@ -0,0 +1,24 @@ +# Copyright 2022 NNAISENSE SA +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Problem types for Black-box Optimisation +""" + + +__all__ = ("bbob_utilities", "bbob_problem", "bbob_noiseless_suite") + + +from . import bbob_noiseless_suite, bbob_problem, bbob_utilities +from .bbob_problem import BBOBProblem diff --git a/src/evotorch/bbo/bbob_noiseless_suite.py b/src/evotorch/bbo/bbob_noiseless_suite.py new file mode 100644 index 00000000..351cc2ac --- /dev/null +++ b/src/evotorch/bbo/bbob_noiseless_suite.py @@ -0,0 +1,516 @@ +# Copyright 2022 NNAISENSE SA +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" Implementation of the Real-Parameter Black-Box Optimization Benchmarking 2009 functions +""" + +from typing import Type + +import numpy as np +import torch + +from evotorch.bbo import bbob_utilities +from evotorch.bbo.bbob_problem import BBOBProblem + + +class Sphere(BBOBProblem): + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + return torch.norm(z, dim=-1).pow(2) + + +class SeparableEllipsoidal(BBOBProblem): + def _initialize_meta_variables(self): + # Initialize 10^ (6 * standardized range) + self.power_range = torch.pow(10, 6 * self.make_standardized_range()).unsqueeze(0) + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # T_osz(x - x_opt) + return bbob_utilities.T_osz(x - self._x_opt.unsqueeze(0)) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + return torch.sum( + self.power_range * z.pow(2.0), + dim=-1, + ) + + +class SeparableRastrigin(BBOBProblem): + def _initialize_meta_variables(self): + self.lambda_10 = self.make_lambda_alpha(10, diagonal_only=True).unsqueeze(0) + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # Lambda^10 T^0.2_asy ( T_osz (x - x_opt) ) + return self.lambda_10 * bbob_utilities.T_beta_asy( + values=bbob_utilities.T_osz(values=x - self._x_opt.unsqueeze(0)), + beta=0.2, + ) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + return 10 * (self.solution_length - torch.sum(torch.cos(2 * np.pi * z), dim=-1)) + torch.norm(z, dim=-1).pow( + 2.0 + ) + + +class BucheRastrigin(BBOBProblem): + def _initialize_meta_variables(self): + standardized_range = self.make_standardized_range() + # Values when T_osz(x_i - x_opt_i) is positive and i is odd + self.s_positive_odd = 10 * torch.pow(10, 0.5 * standardized_range).unsqueeze(0) + # Values when T_osz(x_i - x_opt_i) is negative or i is even + self.s_negative_even = torch.pow(10, 0.5 * standardized_range).unsqueeze(0) + # Mask for even values. Note that this is for i = 1 ... D, so we actually offset by one so that the first value is counted as odd + self.even_mask = torch.as_tensor( + [i + 1 % 2 == 0 for i in range(self.solution_length)], dtype=torch.bool, device=self.device + ) + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # s T_osz (x - x_opt) where s_i = s_positive_odd when T_osz(x_i - x_opt_i) is positive and i is odd, and s_i = s_negative_even otherwise + pre_z = bbob_utilities.T_osz(x - self._x_opt.unsqueeze(0)) + # Branching on whether pre_z > 0 + s_values = torch.where(pre_z > 0, self.s_positive_odd, self.s_negative_even) + # Always s_negative_even when the value has an even index + s_values[:, self.even_mask] = self.s_negative_even[:, self.even_mask] + # s * pre_z + return s_values * pre_z + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + return ( + 10 * (self.solution_length - torch.sum(torch.cos(2 * np.pi * z), dim=-1)) + + torch.norm(z, dim=-1).pow(2.0) + + 100 * bbob_utilities.f_pen(x) + ) + + +class LinearSlope(BBOBProblem): + def make_x_opt(self) -> torch.Tensor: + # Linear slope has special global optimum at 5 * +/- 1 + return 5 * self.make_random_binary_vector() + + def _initialize_meta_variables(self): + # Initialize 10^ (standardized range) + power_range = torch.pow(10, self.make_standardized_range()) + self.s = (torch.sign(self._x_opt) * power_range).unsqueeze(0) + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # x unless x_i x_opt_i > 5^2, in which case x_opt_i + return torch.where( + x * self._x_opt.unsqueeze(0) < 25, + x, + self._x_opt.unsqueeze(0), + ) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + return torch.sum( + 5 * torch.abs(self.s) - self.s * z, + dim=-1, + ) + + +class AttractiveSector(BBOBProblem): + def _initialize_meta_variables(self): + self.Q = self.make_random_orthogonal_matrix() + self.R = self.make_random_orthogonal_matrix() + self.lambda_10 = self.make_lambda_alpha(10.0, diagonal_only=True).unsqueeze(0) + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # Q lambda^10 R (x - x_opt) + return bbob_utilities.apply_orthogonal_matrix( + self.lambda_10 * bbob_utilities.apply_orthogonal_matrix(x - self._x_opt.unsqueeze(0), self.R), + self.Q, + ) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + s = torch.where(z * self._x_opt.unsqueeze(0) > 0, 100, 1) + return bbob_utilities.T_osz( + torch.sum( + (s * z).pow(2.0), + dim=-1, + ) + ).pow(0.9) + + +class StepEllipsoidal(BBOBProblem): + def _initialize_meta_variables(self): + self.Q = self.make_random_orthogonal_matrix() + self.R = self.make_random_orthogonal_matrix() + self.lambda_10 = self.make_lambda_alpha(10.0, diagonal_only=True).unsqueeze(0) + standardized_range = self.make_standardized_range() + self.weighted_norm_coeffs = torch.pow(10, 2 * standardized_range).unsqueeze(0) + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # R lambda^10 (x - x_opt) + z_hat = self.lambda_10 * bbob_utilities.apply_orthogonal_matrix(x - self._x_opt.unsqueeze(0), self.R) + z_bar = torch.where( + torch.abs(z_hat) > 0.5, + bbob_utilities.nearest_integer(0.5 + z_hat), + bbob_utilities.nearest_integer(0.5 + 10 * z_hat) / 10, + ) + # Q z bar + return bbob_utilities.apply_orthogonal_matrix(z_bar, self.Q) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + # Absolute value of zhat_1 / 10^4. Note that here zhat_1 refers to element at index 0 + zhat_1_div_104 = torch.abs(z[:, 0]) / (10**4) + # Sum of weighted norm + weighted_norm = torch.sum(self.weighted_norm_coeffs * z.pow(2.0), dim=-1) + # Branching value gives max + f_base = 0.1 * torch.where(weighted_norm > zhat_1_div_104, weighted_norm, zhat_1_div_104) + return f_base + bbob_utilities.f_pen(x) + + +class RosenbrockOriginal(BBOBProblem): + def make_x_opt(self) -> torch.Tensor: + # Linear slope has special global optimum from U(-3, 3) + return 6 * (self.make_uniform(self.solution_length) - 0.5) + + def _initialize_meta_variables(self): + self.z_coeff = max(1, np.sqrt(self.solution_length) / 8) + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # max(1, sqrt(d)/8) (x - x_opt) + 1 + return self.z_coeff * (x - self._x_opt.unsqueeze(0)) + 1 + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + z_starts_at_0 = z[:, : self.solution_length - 1] + z_starts_at_1 = z[:, 1:] + return torch.sum(100 * (z_starts_at_0.pow(2.0) - z_starts_at_1).pow(2.0) + (z_starts_at_0 - 1).pow(2.0), dim=-1) + + +class RosenbrockRotated(BBOBProblem): + def make_x_opt(self) -> torch.Tensor: + # Linear slope has special global optimum R^T (ones) / (2 z_coeff) + return ( + bbob_utilities.apply_orthogonal_matrix(self.make_ones(self.solution_length).unsqueeze(0), self.R.T) + / (2 * self.z_coeff) + )[0] + + def initialize_meta_variables(self): + # x_opt must set manually for this task (note that this is hidden in the source code of COCO) + # see: https://github.com/numbbo/coco/blob/master/code-experiments/src/f_rosenbrock.c#L157 + # so we actually override initialize_meta_variables, rather than _initialize_meta_variables, so that x_opt can be set after R is initialized + self.z_coeff = max(1, np.sqrt(self.solution_length) / 8) + self.R = self.make_random_orthogonal_matrix() + self._x_opt = self.make_x_opt() + self._f_opt = self.make_f_opt() + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # max(1, sqrt(d)/8) R x + 1/2 + return 1 / 2 + self.z_coeff * bbob_utilities.apply_orthogonal_matrix(x, self.R) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + z_starts_at_0 = z[:, : self.solution_length - 1] + z_starts_at_1 = z[:, 1:] + return torch.sum(100 * (z_starts_at_0.pow(2.0) - z_starts_at_1).pow(2.0) + (z_starts_at_0 - 1).pow(2.0), dim=-1) + + +class HighConditioningEllipsoidal(BBOBProblem): + def _initialize_meta_variables(self): + # Initialize 10^ (6 * standardized range) + self.power_range = torch.pow(10, 6 * self.make_standardized_range()).unsqueeze(0) + self.R = self.make_random_orthogonal_matrix() + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # T_osz(R(x - x_opt)) + return bbob_utilities.T_osz(bbob_utilities.apply_orthogonal_matrix(x - self._x_opt.unsqueeze(0), self.R)) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + return torch.sum( + self.power_range * z.pow(2.0), + dim=-1, + ) + + +class Discus(BBOBProblem): + def _initialize_meta_variables(self): + self.R = self.make_random_orthogonal_matrix() + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # T_osz(R(x - x_opt)) + return bbob_utilities.T_osz(bbob_utilities.apply_orthogonal_matrix(x - self._x_opt.unsqueeze(0), self.R)) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + z_starts_at_1 = z[:, 1:] + return 1e6 * z[:, 0].pow(2.0) + torch.sum(z_starts_at_1.pow(2.0), dim=-1) + + +class BentCigar(BBOBProblem): + def _initialize_meta_variables(self): + self.R = self.make_random_orthogonal_matrix() + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # R T_asy^0.5 (R(x - x_opt)) + return bbob_utilities.apply_orthogonal_matrix( + bbob_utilities.T_beta_asy( + bbob_utilities.apply_orthogonal_matrix(x - self._x_opt.unsqueeze(0), self.R), beta=0.5 + ), + self.R, + ) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + z_starts_at_1 = z[:, 1:] + return z[:, 0].pow(2.0) + 1e6 * torch.sum(z_starts_at_1.pow(2.0), dim=-1) + + +class SharpRidge(BBOBProblem): + def _initialize_meta_variables(self): + self.R = self.make_random_orthogonal_matrix() + self.Q = self.make_random_orthogonal_matrix() + self.lambda_10 = self.make_lambda_alpha(10.0, diagonal_only=True).unsqueeze(0) + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # Q Lambda^10 R (x - x_opt) + return bbob_utilities.apply_orthogonal_matrix( + self.lambda_10 * bbob_utilities.apply_orthogonal_matrix(x - self._x_opt.unsqueeze(0), self.R), + self.Q, + ) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + z_starts_at_1 = z[:, 1:] + return z[:, 0].pow(2.0) + 100 * torch.sum(z_starts_at_1.pow(2.0), dim=-1).pow(0.5) + + +class DifferentPowers(BBOBProblem): + def _initialize_meta_variables(self): + self.R = self.make_random_orthogonal_matrix() + self.power_range = (2 + 4 * self.make_standardized_range()).unsqueeze(0) + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # R (x - x_opt) + return bbob_utilities.apply_orthogonal_matrix(x - self._x_opt.unsqueeze(0), self.R) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + return torch.sqrt(torch.sum(torch.abs(z).pow(self.power_range), dim=-1)) + + +class NonSeparableRastrigin(BBOBProblem): + def _initialize_meta_variables(self): + self.lambda_10 = self.make_lambda_alpha(10, diagonal_only=True).unsqueeze(0) + self.Q = self.make_random_orthogonal_matrix() + self.R = self.make_random_orthogonal_matrix() + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # R Lambda^10 Q T^0.2_asy ( T_osz (R(x - x_opt)) ) + return bbob_utilities.apply_orthogonal_matrix( + self.lambda_10 + * bbob_utilities.apply_orthogonal_matrix( + bbob_utilities.T_beta_asy( + values=bbob_utilities.T_osz( + values=bbob_utilities.apply_orthogonal_matrix( + x - self._x_opt.unsqueeze(0), + self.R, + ) + ), + beta=0.2, + ), + self.Q, + ), + self.R, + ) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + return 10 * (self.solution_length - torch.sum(torch.cos(2 * np.pi * z), dim=-1)) + torch.norm(z, dim=-1).pow( + 2.0 + ) + + +class Weierstrass(BBOBProblem): + def _initialize_meta_variables(self): + self.lambda_hundredth = self.make_lambda_alpha(1 / 100, diagonal_only=True).unsqueeze(0) + self.Q = self.make_random_orthogonal_matrix() + self.R = self.make_random_orthogonal_matrix() + self.f0 = sum([(0.5**k) * np.cos(2 * np.pi * (3**k) / 2) for k in range(12)]) + self.half_pow_k = torch.pow(0.5, torch.arange(12, dtype=self.dtype, device=self.device)).reshape(1, 1, -1) + self.three_pow_k = torch.pow(3, torch.arange(12, dtype=self.dtype, device=self.device)).reshape(1, 1, -1) + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # R Lambda^1/100 Q T_osz (R(x - x_opt)) + return bbob_utilities.apply_orthogonal_matrix( + self.lambda_hundredth + * bbob_utilities.apply_orthogonal_matrix( + bbob_utilities.T_osz( + values=bbob_utilities.apply_orthogonal_matrix( + x - self._x_opt.unsqueeze(0), + self.R, + ) + ), + self.Q, + ), + self.R, + ) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + return (10 / self.solution_length) * ( + torch.sum( + torch.sum( + self.half_pow_k * torch.cos(2 * np.pi * self.three_pow_k * (z.unsqueeze(-1) + 1 / 2)), + dim=-1, + ) + - self.f0, + dim=-1, + ) + ).pow(3.0) + (10 / self.solution_length) * bbob_utilities.f_pen(x) + + +class SchaffersF7(BBOBProblem): + def _initialize_meta_variables(self): + self.lambda_10 = self.make_lambda_alpha(10, diagonal_only=True).unsqueeze(0) + self.Q = self.make_random_orthogonal_matrix() + self.R = self.make_random_orthogonal_matrix() + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # Lambda^10 Q T^0.5_asy ( (R(x - x_opt) ) + return self.lambda_10 * bbob_utilities.apply_orthogonal_matrix( + bbob_utilities.T_beta_asy( + values=bbob_utilities.apply_orthogonal_matrix( + x - self._x_opt.unsqueeze(0), + self.R, + ), + beta=0.5, + ), + self.Q, + ) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + z_starts_at_0 = z[:, : self.solution_length - 1] + z_starts_at_1 = z[:, 1:] + s = torch.sqrt(z_starts_at_0.pow(2.0) + z_starts_at_1.pow(2.0)) + return ( + (1 / (self.solution_length - 1)) + * torch.sum(torch.sqrt(s) + torch.sqrt(s) * torch.sin(50 * s.pow(0.2)).pow(2.0), dim=-1) + ).pow(2.0) + 10 * bbob_utilities.f_pen(x) + + +class SchaffersF7IllConditioned(BBOBProblem): + def _initialize_meta_variables(self): + self.lambda_1000 = self.make_lambda_alpha(1000, diagonal_only=True).unsqueeze(0) + self.Q = self.make_random_orthogonal_matrix() + self.R = self.make_random_orthogonal_matrix() + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # Lambda^1000 Q T^0.5_asy ( (R(x - x_opt) ) + return self.lambda_1000 * bbob_utilities.apply_orthogonal_matrix( + bbob_utilities.T_beta_asy( + values=bbob_utilities.apply_orthogonal_matrix( + x - self._x_opt.unsqueeze(0), + self.R, + ), + beta=0.5, + ), + self.Q, + ) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + z_starts_at_0 = z[:, : self.solution_length - 1] + z_starts_at_1 = z[:, 1:] + s = torch.sqrt(z_starts_at_0.pow(2.0) + z_starts_at_1.pow(2.0)) + return ( + (1 / (self.solution_length - 1)) + * torch.sum(torch.sqrt(s) + torch.sqrt(s) * torch.sin(50 * s.pow(0.2)).pow(2.0), dim=-1) + ).pow(2.0) + 10 * bbob_utilities.f_pen(x) + + +class CompositeGriewankRosenbrock(BBOBProblem): + def make_x_opt(self) -> torch.Tensor: + # GriewankRosenbrock has special global optimum R^T (ones) / (2 z_coeff) + return ( + bbob_utilities.apply_orthogonal_matrix(self.make_ones(self.solution_length).unsqueeze(0), self.R.T) + / (2 * self.z_coeff) + )[0] + + def initialize_meta_variables(self): + # x_opt must set manually for this task (note that this is hidden in the source code of COCO) + # see: https://github.com/numbbo/coco/blob/master/code-experiments/src/f_griewank_rosenbrock.c#L186 + # so we actually override initialize_meta_variables, rather than _initialize_meta_variables, so that x_opt can be set after R is initialized + self.z_coeff = max(1, np.sqrt(self.solution_length) / 8) + self.R = self.make_random_orthogonal_matrix() + self._x_opt = self.make_x_opt() + self._f_opt = self.make_f_opt() + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + # max(1, sqrt(d)/8) R x + 1/2 + return 1 / 2 + self.z_coeff * bbob_utilities.apply_orthogonal_matrix(x, self.R) + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + z_starts_at_0 = z[:, : self.solution_length - 1] + z_starts_at_1 = z[:, 1:] + # Compute rosenbrock values + rosenbrock_rotated = 100 * (z_starts_at_0.pow(2.0) - z_starts_at_1).pow(2.0) + (z_starts_at_0 - 1).pow(2.0) + return (10 / (self.solution_length - 1)) * torch.sum( + rosenbrock_rotated / 4000 - torch.cos(rosenbrock_rotated), dim=-1 + ) + 10 + + +class Schwefel(BBOBProblem): + def make_x_opt(self) -> torch.Tensor: + return 4.2096874633 * self.random_binary[0] / 2 + + def initialize_meta_variables(self): + # x_opt must set manually for this task + self.lambda_10 = self.make_lambda_alpha(10, diagonal_only=True).unsqueeze(0) + self.random_binary = self.make_random_binary_vector().unsqueeze(0) + self._x_opt = self.make_x_opt() + self._f_opt = self.make_f_opt() + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + x_hat = 2 * self.random_binary * x + z_hat = x_hat + z_hat[:, 1:] = z_hat[:, 1:] + 0.25 * (x_hat[:, :-1] - 2 * torch.abs(self._x_opt[:-1]).unsqueeze(0)) + z = 100 * ( + self.lambda_10 * (z_hat - 2 * torch.abs(self._x_opt).unsqueeze(0)) + 2 * torch.abs(self._x_opt).unsqueeze(0) + ) + return z + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + return (-1 / (100 * self.solution_length)) * torch.sum(z * torch.sin(torch.sqrt(torch.abs(z))), dim=-1) + ( + 4.189828872724339 + 100 * bbob_utilities.f_pen(z / 100) + ) + + +# Array of functions in ordered form e.g. so that they can be accessed like 'F1' rather than by name +_functions = [ + Sphere, + SeparableEllipsoidal, + SeparableRastrigin, + BucheRastrigin, + LinearSlope, + AttractiveSector, + StepEllipsoidal, + RosenbrockOriginal, + RosenbrockRotated, + HighConditioningEllipsoidal, + Discus, + BentCigar, + SharpRidge, + DifferentPowers, + NonSeparableRastrigin, + Weierstrass, + SchaffersF7, + SchaffersF7IllConditioned, + CompositeGriewankRosenbrock, + Schwefel, +] + + +def get_function_i(i: int) -> Type[BBOBProblem]: + """Get the ith function, for i in 1 ... 24 + Args: + i (int): The index of the function to obtain, between 1 and 24 + Returns: + function_i (BBOBProblem): The ith function Fi + """ + if i < 1 or i > 24: + raise ValueError("The BBOB Noiseless suite defines only functions F1 ... F24") + function_i = _functions[i - 1] + return function_i diff --git a/src/evotorch/bbo/bbob_problem.py b/src/evotorch/bbo/bbob_problem.py new file mode 100644 index 00000000..c1816cd9 --- /dev/null +++ b/src/evotorch/bbo/bbob_problem.py @@ -0,0 +1,210 @@ +# Copyright 2022 NNAISENSE SA +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from typing import List, Optional + +import numpy as np +import torch + +from evotorch.bbo.bbob_utilities import lambda_alpha, random_binary_vector, random_orthogonal_matrix, standardized_range +from evotorch.core import BoundsPairLike, Device, DType, Problem, SolutionBatch + + +class BBOBProblem(Problem): + def __init__( + self, + solution_length: int, + targets: List[int] = [trg for trg in range(-8, 4)], + initial_bounds: Optional[BoundsPairLike] = (-3, 3), + bounds: Optional[BoundsPairLike] = None, + dtype: Optional[DType] = torch.float64, + eval_dtype: Optional[DType] = torch.float64, + device: Optional[Device] = None, + seed: Optional[int] = None, + ): + + super().__init__( + objective_sense="min", + objective_func=None, + initial_bounds=initial_bounds, + bounds=bounds, + solution_length=solution_length, + dtype=dtype, + eval_dtype=eval_dtype, + device=device, + eval_data_length=None, + seed=seed, + num_actors=None, + actor_config=None, + num_gpus_per_actor=None, + num_subbatches=None, + subbatch_size=None, + store_solution_stats=None, + vectorized=True, + ) + + # Initialize meta variables + self.initialize_meta_variables() + self._targets, _ = torch.sort(self.make_tensor(targets), descending=True) + self._targets_hit = torch.zeros_like(self._targets, dtype=torch.bool) + self._targets_hit_at_feval = -1 * torch.ones_like(self._targets, dtype=torch.long) + self._n_fevals = 0 + + """ Extra BBOB-specific generator functions that ensure compliant dtype, device and generator + """ + + def make_f_opt(self) -> torch.Tensor: + """Generate fitness shift f_opt. f_opt is sampled from the Cauchy distribution with location 0 and scale 100. + This means that the median distance from 0 is 100. The value is then clamped to the range [-1000, 1000] and rounded to 2 decimal places. + Cauchy generation is done on the same device, dtype and generator by doing uniform sampling followed by inverse transform sampling. + """ + # Generate uniformly from [0,1] to sample in the CDF + cdf_f_opt = self.make_uniform(1) + # CDF(x) is 1/pi arctan((x - loc) / scale) + 1/2 + # Thus x is loc + scale tan(1/2 (2 CDF(x) - 1) pi) + f_opt_sampled_from_cauchy = 100 * torch.tan(0.5 * (2 * cdf_f_opt - 1) * np.pi) + # Clamp to range [-1000, 1000] + f_opt_clamped = torch.clamp(f_opt_sampled_from_cauchy, -1000, 1000) + # Round to 2 decimal places + f_opt_rounded = torch.round(f_opt_clamped, decimals=2) + return f_opt_rounded + + def make_x_opt(self) -> torch.Tensor: + """Make the optimal point x_opt. By default, this is drawn from the uniform distribution U[-4, 4]^d""" + return 8 * (self.make_uniform(self.solution_length) - 0.5) + + def make_lambda_alpha(self, alpha: float, diagonal_only: bool = True) -> torch.Tensor: + """Make the Lambda^alpha variable for a given alpha + Args: + alpha (float): The alpha parameter to the matrix. + diagonal_only (bool): Whether to only return the diagonal elements. + """ + return lambda_alpha( + alpha=alpha, + dimension=self.solution_length, + diagonal_only=diagonal_only, + dtype=self.dtype, + device=self.device, + ) + + def make_random_binary_vector(self) -> torch.Tensor: + """Make random binary vector 1^+_- where each element is either -1 or 1 with probability 0.5""" + return random_binary_vector( + dimension=self.solution_length, dtype=self.dtype, device=self.device, generator=self.generator + ) + + def make_standardized_range(self) -> torch.Tensor: + """Make the standardized range (i-1)/(D-1) for i = 1 ... D""" + return standardized_range(dimension=self.solution_length, dtype=self.dtype, device=self.device) + + def make_random_orthogonal_matrix(self) -> torch.Tensor: + """Make a random orthogonal matrix ("R" and "Q") using the Gram-Schmidt orthonormalization.""" + return random_orthogonal_matrix( + dimension=self.solution_length, + dtype=self.dtype, + device=self.device, + generator=self.generator, + ) + + """ Functionality for specifying which of the above maker functions need to be called to specify the problem + """ + + def _initialize_meta_variables(self): + """Initialise meta variables. Override to define problem-specific meta-variables.""" + pass + + def initialize_meta_variables(self): + """Initialize meta variables. Problem-specific meta variables should be instantiated by overriding _initialize_meta_variables""" + # All function definitions require f_opt and x_opt + self._x_opt = self.make_x_opt() + self._f_opt = self.make_f_opt() + # Create problem-specific meta variables + self._initialize_meta_variables() + + """ Implementation of the objective function in vectorized form + """ + + def map_x_to_z(self, x: torch.Tensor) -> torch.Tensor: + """The x to z mapping used in all function definitions. By default, this simply maps z = x - x_opt, but it should be overridden for other use-cases + Args: + x (torch.Tensor): The values x to map, of shape [num_samples, dimension] + Returns: + z (torch.Tensor): The mapped values z, of shape [num_samples, dimension] + """ + # Subtract x_opt from x and return + z = x - self._x_opt.unsqueeze(0) + return z + + def _apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + """Apply the function to the given values z. Override to define problem-specific function + Args: + z (torch.Tensor): The values to apply the function to, of shape [num_samples, dimension] + x (torch.Tensor): The original, untransformed, values, of shape [num_samples, dimension] + Returns: + f_z (torch.Tensor): The output of applying the function to z, of shape [num_samples,0] + """ + raise NotImplementedError("Function must be defined for BBOBProblem") + + def apply_function(self, z: torch.Tensor, x: torch.Tensor) -> torch.Tensor: + """Apply the function to the given values z. Note that self._apply_function should be overridden for problem-specific function + Args: + z (torch.Tensor): The values to apply the function to, of shape [num_samples, dimension] + x (torch.Tensor): The original, untransformed, values, of shape [num_samples, dimension] + Returns: + f_z (torch.Tensor): The output of applying the function to z, of shape [num_samples,0] + """ + # Shift result by f_opt and return + f_z = self._apply_function(z, x) + self._f_opt + return f_z + + @property + def log_closest(self) -> torch.Tensor: + """The logarithm of the best discovered solution so far""" + return self._log_closest + + @log_closest.setter + def log_closest(self, new_log_closest): + self._log_closest = new_log_closest + + def _evaluate_batch(self, batch: SolutionBatch) -> None: + # Get x from batch + x = batch.values.clone() + # Map x to z + z = self.map_x_to_z(x) + # Get f(x) from function application to z + f_x = self.apply_function(z, x) + + n_sol = len(batch) + + # Compute log distance from f_opt + log_f_x = torch.log10(f_x - self._f_opt) + + # Update any targets hit + targets_hit = log_f_x.unsqueeze(-1) < self._targets.unsqueeze(0) + indices = torch.arange(len(batch), dtype=torch.long, device=batch.device).unsqueeze(-1) + targets_hit_at_feval = torch.logical_not(targets_hit).to(torch.long) * 100 * (self._n_fevals + n_sol) + ( + self._n_fevals + indices + 1 + ) + min_new_target_hit = targets_hit_at_feval.amin(dim=0) + new_target_hit = torch.logical_and(targets_hit.any(dim=0), torch.logical_not(self._targets_hit)) + + self._targets_hit_at_feval = torch.where(new_target_hit, min_new_target_hit, self._targets_hit_at_feval) + self._targets_hit = torch.logical_or(self._targets_hit, new_target_hit) + + # Increment number of fitness evaluations + self._n_fevals += n_sol + + # Assign fitnesses to batch + batch.set_evals(f_x) diff --git a/src/evotorch/bbo/bbob_utilities.py b/src/evotorch/bbo/bbob_utilities.py new file mode 100644 index 00000000..01a4e5ae --- /dev/null +++ b/src/evotorch/bbo/bbob_utilities.py @@ -0,0 +1,249 @@ +# Copyright 2022 NNAISENSE SA +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Optional + +import torch + +from evotorch.tools import Device, DType + +""" Various utility functionality for implementing BBOB functions. See section 0.2 of + Hansen, Nikolaus, et al. "Real-Parameter Black-Box Optimization Benchmarking 2009: Noiseless Functions Definitions." +""" + + +def nearest_integer(values: torch.Tensor) -> torch.Tensor: + """Alias for rounding to nearest whole integer. Included for unambiguous implementation of BBOB benchmark functions + Args: + values (torch.Tensor): The values to round to the nearest integer. + Returns: + torch.Tensor: The values rounded to the nearest integer. + """ + return torch.round(values) + + +def standardized_range( + dimension: int, dtype: DType = torch.float32, device: Device = torch.device("cpu") +) -> torch.Tensor: + """The commonly used standardized range (i-1)/(D-1) for i = 1 ... D + Args: + dimension (int): The dimension D of the range. + dtype (Dtype): The datatype of the generated vector. + device (Device): The device of the generated vector. + Returns: + i_values (torch.Tensor): The generated standardized range. + """ + # Note that the paper states (i - 1) for i = 1 ... D but as arange starts at zero, we can drop the constant + i_values = (torch.arange(dimension, dtype=dtype, device=device)) / (dimension - 1) + return i_values + + +def lambda_alpha( + alpha: float, + dimension: int, + diagonal_only: bool = True, + dtype: DType = torch.float32, + device: Device = torch.device("cpu"), +) -> torch.Tensor: + """The Lambda^alpha matrix, which is diagonal only with Lamba^alpha_i,i = alpha^(1/2 (i-1)/(dimension - 1)) + Args: + alpha (float): The alpha parameter to the matrix. + dimension (int): The dimension of the matrix. + diagonal_only (bool): Whether to only return the diagonal elements. + dtype (Dtype): The datatype of the generated matrix. + device (Device): The device of the generated matrix. + Returns: + alpha_matrix: The parameterised matrix Lambda^alpha. + If diagonal_only, then it is the diagonal elements of shape [dimension,] + Otherwise, then it is the matrix of shape [dimension, dimension,] + """ + # Place alpha in a tensor so it can be used within torch.pow + alpha_tensor = torch.as_tensor(alpha, dtype=dtype, device=device) + # Exponents of diagonal terms of Lambda^alpha. + exponents = 0.5 * standardized_range(dimension, dtype=dtype, device=device) + # Diagonal elements of Lambda^alpha + alpha_diagonal = torch.pow(alpha_tensor, exponents) + # Branching on whether diagonal_only + if diagonal_only: + alpha_matrix = alpha_diagonal + else: + alpha_matrix = torch.diag(alpha_diagonal) + return alpha_matrix + + +def f_pen(values: torch.Tensor) -> torch.Tensor: + """The f_pen magnitude penalty function, in vectorized form. + For a given sample x, the penalty is the sum of the element-wise penalty. + The element-wise penalty for element x_i is max(0, |x_i| - 5)^2 + Args: + values (torch.Tensor): The values to apply f_pen to, of shape [num_samples, dimension,] + Returns: + penalties (torch.Tensor): The penalised values, of shape [num_samples,] + """ + # Compute element-wise penalty + elementwise_penalty = torch.clamp( + torch.abs(values) - 5, # Penalty applies whenever the absolute of the value is less than 5. + min=0.0, # Minimum penalty is zero, cannot be based on a negative value (implies value within [-5,5]) + max=None, # No upper bound on penalty + ).pow(2.0) + + # Sum across the individual samples + penalties = torch.sum(elementwise_penalty, dim=-1) + return penalties + + +def random_binary_vector( + dimension: int, + dtype: DType = torch.float32, + device: Device = torch.device("cpu"), + generator: Optional[torch.Generator] = None, +) -> torch.Tensor: + """The random binary vector 1^+_- where each element is either -1 or 1 with probability 0.5 + Args: + dimension (int): The dimension of the random binary vector. + dtype (Dtype): The datatype of the generated vector. + device (Device): The device of the generated vector. + generator (Optional[torch.Generator]): An optional generator for the randomised values. + Returns: + random_vec (torch.Tensor): The generated random binary vector + """ + # Sample the uniform distribution [0,1] in the given dimension + uniform_noise = torch.rand(dimension, dtype=dtype, device=device, generator=generator) + # Round the noise to give a uniform distribution over 0/1 values, and rescale to -1/1 + random_vec = 2 * (torch.round(uniform_noise) - 0.5) + return random_vec + + +def _gram_schmidt_projection(u: torch.Tensor, v: torch.Tensor) -> torch.Tensor: + """The Gram-Scmidt projection operator u / + Args: + u, v (torch.Tensor): The vector arguments to the projection, of shapes [dimension,] + Returns: + projection (torch.Tensor): The projected vector of shape [dimension,] + """ + # Compute dot products + u_dot_v = torch.dot(u, v) + u_dot_u = torch.dot(u, u) + # Construct projection + projection = u * u_dot_v / u_dot_u + return projection + + +def random_orthogonal_matrix( + dimension: int, + dtype: DType = torch.float32, + device: Device = torch.device("cpu"), + generator: Optional[torch.Generator] = None, +) -> torch.Tensor: + """Generate a random orthogonal matrix ("R" and "Q") using the Gram-Schmidt orthonormalization. + Note that this process uses the notation found on Wikipedia https://en.wikipedia.org/wiki/Gram%E2%80%93Schmidt_process + Args: + dimension (int): The dimension of the random orthogonal matrix. + dtype (Dtype): The datatype of the generated orthogonal matrix. + device (Device): The device of the generated orthogonal matrix. + generator (Optional[torch.Generator]): An optional generator for the randomised values. + Returns: + orthogonal_matrix (torch.Tensor): The generated random orthogonal matrix. + """ + # Generate normally distributed vectors + vs = torch.randn( + ( + dimension, + dimension, + ), + dtype=dtype, + device=device, + generator=generator, + ) + + # Compute u vectors -- first u is simply first v + us = [vs[0]] + for u_index in range(1, dimension): + v = vs[u_index] + projection_sum = sum([_gram_schmidt_projection(u, v) for u in us]) + u = v - projection_sum + us.append(u) + + # Convert u vectors to e vectors + es = [u / torch.norm(u) for u in us] + + # Convert back into full tensor form + orthogonal_matrix = torch.stack(es, dim=0) + + return orthogonal_matrix + + +def T_beta_asy(values: torch.Tensor, beta: float) -> torch.Tensor: + """The T^beta_asy function + Args: + values (torch.Tensor): The values to apply the T^beta_asy function to, of shape [num_samples, dimension,] + beta (float): The beta parameter of the function + Returns: + transformed_values (torch.Tensor): The transformed values of shape [num_samples, dimension,] + """ + # Get the dimension + dimension = values.shape[-1] + # Exponents of values when values are positive. + exponents = 1 + beta * torch.sqrt(torch.abs(values)) * standardized_range( + dimension=dimension, dtype=values.dtype, device=values.device + ).unsqueeze(0) + + # Branching on whether the values are positive... when <= 0, values are instead left as passed + transformed_values = torch.where(values > 0, torch.pow(values, exponents), values) + + return transformed_values + + +def T_osz(values: torch.Tensor, epsilon: float = 1e-7) -> torch.Tensor: + """The T_osz function + Args: + values (torch.Tensor): The values to apply the T_osz function to, of shape [num_samples, dimension,] + epsilon (float): Error threshold for assuming a value is zero. The paper states that xhat and sign(x) have specific behavior at x = 0 + Here, we assume that when |x| < epsilon, that rule should apply. + Returns: + transformed_values (torch.Tensor): The transformed values of shape [num_samples, dimension,] + """ + # Identify the values to treat as zero + values_close_to_zero = torch.abs(values) < epsilon + # Precompute x hat, sign x, c1 and c2 + + # xhat is log(|x|) unless x is 0, in which case xhat is also 0 + xhat = torch.log(torch.abs(values)) + xhat[values_close_to_zero] = 0.0 + + # sign(x) is -1 if x < 0, 1 if x > 0 and 0 if x = 0 + sign_x = torch.sign(values) + sign_x[values_close_to_zero] = 0 + + # c1 is 10 if x > 0, 5.5 otherwise + c1 = torch.where(values > 0, 10, 5.5) + + # c2 is 7.9 if x > 0, 3.1 otherwise + c2 = torch.where(values > 0, 7.9, 3.1) + + # Construct the transformed values + transformed_values = sign_x * torch.exp(xhat + 0.049 * (torch.sin(c1 * xhat) + torch.sin(c2 * xhat))) + + return transformed_values + + +def apply_orthogonal_matrix(values: torch.Tensor, orthogonal_matrix: torch.Tensor) -> torch.Tensor: + """Apply a given orthogonal matrix R to a given batch of vectors x e.g. Rx + Args: + values (torch.Tensor): The batch of values to apply the orthogonal matrix to, of shape [num_solutions, dimension] + orthogonal_matrix (torch.Tensor): The orthogonal matrix to apply to the values, of shape [dimension, dimension] + Returns: + transformed_values (torch.Tensor): + """ + return torch.matmul(orthogonal_matrix, values.T).T diff --git a/tests/test_bbo.py b/tests/test_bbo.py new file mode 100644 index 00000000..605ee682 --- /dev/null +++ b/tests/test_bbo.py @@ -0,0 +1,22 @@ +import numpy as np +import torch + +from evotorch.bbo import bbob_noiseless_suite, bbob_problem + + +def test_bbob_noiseless_suite_global_optima(): + + n_functions = len(bbob_noiseless_suite._functions) + + dimensions = [2, 5, 10, 20, 40] + + for dimension in dimensions: + + for function_idx in range(1, n_functions + 1): + func: bbob_problem.BBOBProblem = bbob_noiseless_suite.get_function_i(function_idx)(dimension) + batch = func.generate_batch(5) + batch[0].set_values(func._x_opt) + func.evaluate(batch) + eval_of_x_opt = float(batch.evals[0] - func._f_opt) + + assert np.abs(eval_of_x_opt - 0.0) < 1e-7