diff --git a/botorch/test_functions/base.py b/botorch/test_functions/base.py index 4111b64b1d..48ca3cde67 100644 --- a/botorch/test_functions/base.py +++ b/botorch/test_functions/base.py @@ -11,9 +11,10 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import List, Optional, Tuple +from typing import List, Tuple, Union import torch + from botorch.exceptions.errors import InputDataError from torch import Tensor from torch.nn import Module @@ -26,11 +27,17 @@ class BaseTestProblem(Module, ABC): _bounds: List[Tuple[float, float]] _check_grad_at_opt: bool = True - def __init__(self, noise_std: Optional[float] = None, negate: bool = False) -> None: + def __init__( + self, + noise_std: Union[None, float, List[float]] = None, + negate: bool = False, + ) -> None: r"""Base constructor for test functions. Args: - noise_std: Standard deviation of the observation noise. + noise_std: Standard deviation of the observation noise. If a list is + provided, specifies separate noise standard deviations for each + objective in a multiobjective problem. negate: If True, negate the function. """ super().__init__() @@ -60,7 +67,8 @@ def forward(self, X: Tensor, noise: bool = True) -> Tensor: X = X if batch else X.unsqueeze(0) f = self.evaluate_true(X=X) if noise and self.noise_std is not None: - f += self.noise_std * torch.randn_like(f) + _noise = torch.tensor(self.noise_std, device=X.device, dtype=X.dtype) + f += _noise * torch.randn_like(f) if self.negate: f = -f return f if batch else f.squeeze(0) @@ -82,6 +90,7 @@ class ConstrainedBaseTestProblem(BaseTestProblem, ABC): num_constraints: int _check_grad_at_opt: bool = False + constraint_noise_std: Union[None, float, List[float]] = None def evaluate_slack(self, X: Tensor, noise: bool = True) -> Tensor: r"""Evaluate the constraint slack on a set of points. @@ -101,10 +110,11 @@ def evaluate_slack(self, X: Tensor, noise: bool = True) -> Tensor: corresponds to the constraint being feasible). """ cons = self.evaluate_slack_true(X=X) - if noise and self.noise_std is not None: - # TODO: Allow different noise levels for objective and constraints (and - # different noise levels between different constraints) - cons += self.noise_std * torch.randn_like(cons) + if noise and self.constraint_noise_std is not None: + _constraint_noise = torch.tensor( + self.constraint_noise_std, device=X.device, dtype=X.dtype + ) + cons += _constraint_noise * torch.randn_like(cons) return cons def is_feasible(self, X: Tensor, noise: bool = True) -> Tensor: @@ -147,13 +157,24 @@ class MultiObjectiveTestProblem(BaseTestProblem): _ref_point: List[float] _max_hv: float - def __init__(self, noise_std: Optional[float] = None, negate: bool = False) -> None: + def __init__( + self, + noise_std: Union[None, float, List[float]] = None, + negate: bool = False, + ) -> None: r"""Base constructor for multi-objective test functions. Args: - noise_std: Standard deviation of the observation noise. + noise_std: Standard deviation of the observation noise. If a list is + provided, specifies separate noise standard deviations for each + objective. negate: If True, negate the objectives. """ + if isinstance(noise_std, list) and len(noise_std) != len(self._ref_point): + raise InputDataError( + f"If specified as a list, length of noise_std ({len(noise_std)}) " + f"must match the number of objectives ({len(self._ref_point)})" + ) super().__init__(noise_std=noise_std, negate=negate) ref_point = torch.tensor(self._ref_point, dtype=torch.float) if negate: diff --git a/botorch/test_functions/multi_objective.py b/botorch/test_functions/multi_objective.py index e9255fd76e..ad40171218 100644 --- a/botorch/test_functions/multi_objective.py +++ b/botorch/test_functions/multi_objective.py @@ -76,7 +76,7 @@ import math from abc import ABC, abstractmethod from math import pi -from typing import Optional +from typing import List, Union import torch from botorch.exceptions.errors import UnsupportedError @@ -116,7 +116,11 @@ class BraninCurrin(MultiObjectiveTestProblem): _ref_point = [18.0, 6.0] _max_hv = 59.36011874867746 # this is approximated using NSGA-II - def __init__(self, noise_std: Optional[float] = None, negate: bool = False) -> None: + def __init__( + self, + noise_std: Union[None, float, List[float]] = None, + negate: bool = False, + ) -> None: r""" Args: noise_std: Standard deviation of the observation noise. @@ -174,7 +178,7 @@ class DH(MultiObjectiveTestProblem, ABC): def __init__( self, dim: int, - noise_std: Optional[float] = None, + noise_std: Union[None, float, List[float]] = None, negate: bool = False, ) -> None: r""" @@ -334,7 +338,7 @@ def __init__( self, dim: int, num_objectives: int = 2, - noise_std: Optional[float] = None, + noise_std: Union[None, float, List[float]] = None, negate: bool = False, ) -> None: r""" @@ -600,7 +604,7 @@ class GMM(MultiObjectiveTestProblem): def __init__( self, - noise_std: Optional[float] = None, + noise_std: Union[None, float, List[float]] = None, negate: bool = False, num_objectives: int = 2, ) -> None: @@ -926,7 +930,7 @@ def __init__( self, dim: int, num_objectives: int = 2, - noise_std: Optional[float] = None, + noise_std: Union[None, float, List[float]] = None, negate: bool = False, ) -> None: r""" @@ -1234,7 +1238,11 @@ class ConstrainedBraninCurrin(BraninCurrin, ConstrainedBaseTestProblem): _ref_point = [80.0, 12.0] _max_hv = 608.4004237022673 # from NSGA-II with 90k evaluations - def __init__(self, noise_std: Optional[float] = None, negate: bool = False) -> None: + def __init__( + self, + noise_std: Union[None, float, List[float]] = None, + negate: bool = False, + ) -> None: r""" Args: noise_std: Standard deviation of the observation noise. @@ -1337,7 +1345,7 @@ class MW7(MultiObjectiveTestProblem, ConstrainedBaseTestProblem): def __init__( self, dim: int, - noise_std: Optional[float] = None, + noise_std: Union[None, float, List[float]] = None, negate: bool = False, ) -> None: r""" diff --git a/botorch/test_functions/synthetic.py b/botorch/test_functions/synthetic.py index ffc0b67321..6eac4969b4 100644 --- a/botorch/test_functions/synthetic.py +++ b/botorch/test_functions/synthetic.py @@ -47,9 +47,10 @@ from __future__ import annotations import math -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Union import torch +from botorch.exceptions.errors import InputDataError from botorch.test_functions.base import BaseTestProblem, ConstrainedBaseTestProblem from botorch.test_functions.utils import round_nearest from torch import Tensor @@ -64,13 +65,15 @@ class SyntheticTestFunction(BaseTestProblem): def __init__( self, - noise_std: Optional[float] = None, + noise_std: Union[None, float, List[float]] = None, negate: bool = False, bounds: Optional[List[Tuple[float, float]]] = None, ) -> None: r""" Args: - noise_std: Standard deviation of the observation noise. + noise_std: Standard deviation of the observation noise. If a list is + provided, specifies separate noise standard deviations for each + objective in a multiobjective problem. negate: If True, negate the function. bounds: Custom bounds for the function specified as (lower, upper) pairs. """ @@ -802,7 +805,61 @@ def evaluate_true(self, X: Tensor) -> Tensor: # ------------ Constrained synthetic test functions ----------- # -class ConstrainedGramacy(ConstrainedBaseTestProblem, SyntheticTestFunction): +class ConstrainedSyntheticTestFunction( + ConstrainedBaseTestProblem, SyntheticTestFunction +): + r"""Base class for constrained synthetic test functions.""" + + def __init__( + self, + noise_std: Union[None, float, List[float]] = None, + constraint_noise_std: Union[None, float, List[float]] = None, + negate: bool = False, + bounds: Optional[List[Tuple[float, float]]] = None, + ) -> None: + r""" + Args: + noise_std: Standard deviation of the observation noise. If a list is + provided, specifies separate noise standard deviations for each + objective in a multiobjective problem. + constraint_noise_std: Standard deviation of the constraint noise. + If a list is provided, specifies separate noise standard + deviations for each constraint. + negate: If True, negate the function. + bounds: Custom bounds for the function specified as (lower, upper) pairs. + """ + self.constraint_noise_std = self._validate_constraint_noise( + constraint_noise_std + ) + SyntheticTestFunction.__init__( + self, noise_std=noise_std, negate=negate, bounds=bounds + ) + + def _validate_constraint_noise( + self, constraint_noise_std + ) -> Union[None, float, List[float]]: + """ + Validates that constraint_noise_std has length equal to + the number of constraints, if given as a list + + Args: + constraint_noise_std: Standard deviation of the constraint noise. + If a list is provided, specifies separate noise standard + deviations for each constraint. + """ + if ( + isinstance(constraint_noise_std, list) + and len(constraint_noise_std) != self.num_constraints + ): + raise InputDataError( + "If specified as a list, length of constraint_noise_std " + f"({len(constraint_noise_std)}) must match the " + f"number of constraints ({self.num_constraints})" + ) + return constraint_noise_std + + +class ConstrainedGramacy(ConstrainedSyntheticTestFunction): r"""Constrained Gramacy test function. This problem comes from [Gramacy2016]_. The problem is defined @@ -835,7 +892,7 @@ def evaluate_slack_true(self, X: Tensor) -> Tensor: return torch.cat([-c1, -c2], dim=-1) -class ConstrainedHartmann(Hartmann, ConstrainedBaseTestProblem): +class ConstrainedHartmann(Hartmann, ConstrainedSyntheticTestFunction): r"""Constrained Hartmann test function. This is a constrained version of the standard Hartmann test function that @@ -843,11 +900,34 @@ class ConstrainedHartmann(Hartmann, ConstrainedBaseTestProblem): """ num_constraints = 1 + def __init__( + self, + dim: int = 6, + noise_std: Union[None, float] = None, + constraint_noise_std: Union[None, float, List[float]] = None, + negate: bool = False, + bounds: Optional[List[Tuple[float, float]]] = None, + ) -> None: + r""" + Args: + dim: The (input) dimension. + noise_std: Standard deviation of the observation noise. + constraint_noise_std: Standard deviation of the constraint noise. + If a list is provided, specifies separate noise standard + deviations for each constraint. + negate: If True, negate the function. + bounds: Custom bounds for the function specified as (lower, upper) pairs. + """ + self._validate_constraint_noise(constraint_noise_std) + Hartmann.__init__( + self, dim=dim, noise_std=noise_std, negate=negate, bounds=bounds + ) + def evaluate_slack_true(self, X: Tensor) -> Tensor: return -X.norm(dim=-1, keepdim=True) + 1 -class ConstrainedHartmannSmooth(Hartmann, ConstrainedBaseTestProblem): +class ConstrainedHartmannSmooth(Hartmann, ConstrainedSyntheticTestFunction): r"""Smooth constrained Hartmann test function. This is a constrained version of the standard Hartmann test function that @@ -855,11 +935,34 @@ class ConstrainedHartmannSmooth(Hartmann, ConstrainedBaseTestProblem): """ num_constraints = 1 + def __init__( + self, + dim: int = 6, + noise_std: Union[None, float] = None, + constraint_noise_std: Union[None, float, List[float]] = None, + negate: bool = False, + bounds: Optional[List[Tuple[float, float]]] = None, + ) -> None: + r""" + Args: + dim: The (input) dimension. + noise_std: Standard deviation of the observation noise. + constraint_noise_std: Standard deviation of the constraint noise. + If a list is provided, specifies separate noise standard + deviations for each constraint. + negate: If True, negate the function. + bounds: Custom bounds for the function specified as (lower, upper) pairs. + """ + self._validate_constraint_noise(constraint_noise_std) + Hartmann.__init__( + self, dim=dim, noise_std=noise_std, negate=negate, bounds=bounds + ) + def evaluate_slack_true(self, X: Tensor) -> Tensor: return -X.pow(2).sum(dim=-1, keepdim=True) + 1 -class PressureVessel(SyntheticTestFunction, ConstrainedBaseTestProblem): +class PressureVessel(ConstrainedSyntheticTestFunction): r"""Pressure vessel design problem with constraints. The four-dimensional pressure vessel design problem with four black-box @@ -894,7 +997,7 @@ def evaluate_slack_true(self, X: Tensor) -> Tensor: ) -class WeldedBeamSO(SyntheticTestFunction, ConstrainedBaseTestProblem): +class WeldedBeamSO(ConstrainedSyntheticTestFunction): r"""Welded beam design problem with constraints (single-outcome). The four-dimensional welded beam design proble problem with six @@ -950,7 +1053,7 @@ def evaluate_slack_true(self, X: Tensor) -> Tensor: return -torch.stack([g1, g2, g3, g4, g5, g6], dim=-1) -class TensionCompressionString(SyntheticTestFunction, ConstrainedBaseTestProblem): +class TensionCompressionString(ConstrainedSyntheticTestFunction): r"""Tension compression string optimization problem with constraints. The three-dimensional tension compression string optimization problem with @@ -981,7 +1084,7 @@ def evaluate_slack_true(self, X: Tensor) -> Tensor: return -constraints.clamp_max(100) -class SpeedReducer(SyntheticTestFunction, ConstrainedBaseTestProblem): +class SpeedReducer(ConstrainedSyntheticTestFunction): r"""Speed Reducer design problem with constraints. The seven-dimensional speed reducer design problem with eleven black-box diff --git a/test/test_functions/test_multi_objective.py b/test/test_functions/test_multi_objective.py index 480d967c13..39a5a1aab0 100644 --- a/test/test_functions/test_multi_objective.py +++ b/test/test_functions/test_multi_objective.py @@ -8,7 +8,7 @@ from typing import List import torch -from botorch.exceptions.errors import UnsupportedError +from botorch.exceptions.errors import InputDataError, UnsupportedError from botorch.test_functions.base import BaseTestProblem from botorch.test_functions.multi_objective import ( BNH, @@ -63,7 +63,7 @@ def evaluate_true(self, X): class TestBaseTestMultiObjectiveProblem(BotorchTestCase): def test_base_mo_problem(self): for negate in (True, False): - for noise_std in (None, 1.0): + for noise_std in (None, 1.0, [1.0, 2.0]): f = DummyMOProblem(noise_std=noise_std, negate=negate) self.assertEqual(f.noise_std, noise_std) self.assertEqual(f.negate, negate) @@ -75,6 +75,10 @@ def test_base_mo_problem(self): self.assertTrue(torch.equal(f_X, expected_f_X)) with self.assertRaises(NotImplementedError): f.gen_pareto_front(1) + with self.assertRaisesRegex( + InputDataError, "must match the number of objectives" + ): + f = DummyMOProblem(noise_std=[1.0, 2.0, 3.0], negate=negate) class TestBraninCurrin( @@ -155,6 +159,7 @@ def functions(self) -> List[BaseTestProblem]: DTLZ4(dim=5, num_objectives=2), DTLZ5(dim=5, num_objectives=2), DTLZ7(dim=5, num_objectives=2), + DTLZ7(dim=5, num_objectives=2, noise_std=[0.1, 0.2]), ] def test_init(self): @@ -216,7 +221,10 @@ class TestGMM( ): @property def functions(self) -> List[BaseTestProblem]: - return [GMM(num_objectives=4)] + return [ + GMM(num_objectives=4), + GMM(num_objectives=4, noise_std=[0.0, 0.1, 0.2, 0.3]), + ] def test_init(self): f = self.functions[0] @@ -269,7 +277,7 @@ class TestMW7( ): @property def functions(self) -> List[BaseTestProblem]: - return [MW7(dim=3)] + return [MW7(dim=3), MW7(dim=3, noise_std=[0.1, 0.2])] def test_init(self): for f in self.functions: @@ -290,6 +298,8 @@ def functions(self) -> List[BaseTestProblem]: ZDT1(dim=3, num_objectives=2), ZDT2(dim=3, num_objectives=2), ZDT3(dim=3, num_objectives=2), + ZDT3(dim=3, num_objectives=2, noise_std=0.1), + ZDT3(dim=3, num_objectives=2, noise_std=[0.1, 0.2]), ] def test_init(self): @@ -362,7 +372,7 @@ class TestCarSideImpact( ): @property def functions(self) -> List[BaseTestProblem]: - return [CarSideImpact()] + return [CarSideImpact(), CarSideImpact(noise_std=[0.1, 0.2, 0.3, 0.4])] class TestPenicillin( @@ -372,7 +382,7 @@ class TestPenicillin( ): @property def functions(self) -> List[BaseTestProblem]: - return [Penicillin()] + return [Penicillin(), Penicillin(noise_std=[0.1, 0.2, 0.3])] class TestToyRobust( @@ -382,7 +392,7 @@ class TestToyRobust( ): @property def functions(self) -> List[BaseTestProblem]: - return [ToyRobust()] + return [ToyRobust(), ToyRobust(noise_std=[0.1, 0.2])] class TestVehicleSafety( @@ -392,7 +402,7 @@ class TestVehicleSafety( ): @property def functions(self) -> List[BaseTestProblem]: - return [VehicleSafety()] + return [VehicleSafety(), VehicleSafety(noise_std=[0.1, 0.2, 0.3])] # ------------------ Constrained Multi-objective test problems ------------------ # @@ -406,7 +416,7 @@ class TestBNH( ): @property def functions(self) -> List[BaseTestProblem]: - return [BNH()] + return [BNH(), BNH(noise_std=[0.1, 0.2])] class TestSRN( @@ -417,7 +427,7 @@ class TestSRN( ): @property def functions(self) -> List[BaseTestProblem]: - return [SRN()] + return [SRN(), SRN(noise_std=[0.1, 0.2])] class TestCONSTR( @@ -428,7 +438,7 @@ class TestCONSTR( ): @property def functions(self) -> List[BaseTestProblem]: - return [CONSTR()] + return [CONSTR(), CONSTR(noise_std=[0.1, 0.2])] class TestConstrainedBraninCurrin( @@ -439,7 +449,10 @@ class TestConstrainedBraninCurrin( ): @property def functions(self) -> List[BaseTestProblem]: - return [ConstrainedBraninCurrin()] + return [ + ConstrainedBraninCurrin(), + ConstrainedBraninCurrin(noise_std=[0.1, 0.2]), + ] class TestC2DTLZ2( @@ -450,7 +463,11 @@ class TestC2DTLZ2( ): @property def functions(self) -> List[BaseTestProblem]: - return [C2DTLZ2(dim=3, num_objectives=2)] + return [ + C2DTLZ2(dim=3, num_objectives=2), + C2DTLZ2(dim=3, num_objectives=2, noise_std=0.1), + C2DTLZ2(dim=3, num_objectives=2, noise_std=[0.1, 0.2]), + ] def test_batch_exception(self): f = C2DTLZ2(dim=3, num_objectives=2) @@ -466,7 +483,7 @@ class TestDiscBrake( ): @property def functions(self) -> List[BaseTestProblem]: - return [DiscBrake()] + return [DiscBrake(), DiscBrake(noise_std=[0.1, 0.2])] class TestWeldedBeam( @@ -477,7 +494,7 @@ class TestWeldedBeam( ): @property def functions(self) -> List[BaseTestProblem]: - return [WeldedBeam()] + return [WeldedBeam(), WeldedBeam(noise_std=[0.1, 0.2])] class TestOSY( @@ -488,4 +505,4 @@ class TestOSY( ): @property def functions(self) -> List[BaseTestProblem]: - return [OSY()] + return [OSY(), OSY(noise_std=[0.1, 0.2])] diff --git a/test/test_functions/test_synthetic.py b/test/test_functions/test_synthetic.py index 322c0a5ed2..03070de8cc 100644 --- a/test/test_functions/test_synthetic.py +++ b/test/test_functions/test_synthetic.py @@ -14,6 +14,7 @@ ConstrainedGramacy, ConstrainedHartmann, ConstrainedHartmannSmooth, + ConstrainedSyntheticTestFunction, Cosine8, DixonPrice, DropWave, @@ -111,6 +112,34 @@ def test_custom_bounds(self): self.assertAllClose(func.bounds, bounds_tensor) +class DummyConstrainedSyntheticTestFunction(ConstrainedSyntheticTestFunction): + dim = 2 + num_constraints = 1 + _bounds = [(-1, 1), (-1, 1)] + _optimal_value = 0 + + def evaluate_true(self, X: Tensor) -> Tensor: + return -X.pow(2).sum(dim=-1) + + def evaluate_slack_true(self, X: Tensor) -> Tensor: + return -X.norm(dim=-1, keepdim=True) + 1 + + +class TestConstraintNoise(BotorchTestCase): + + functions = [ + DummyConstrainedSyntheticTestFunction(), + DummyConstrainedSyntheticTestFunction(constraint_noise_std=0.1), + DummyConstrainedSyntheticTestFunction(constraint_noise_std=[0.1]), + ] + + def test_constraint_noise_length_validation(self): + with self.assertRaisesRegex( + InputDataError, "must match the number of constraints" + ): + DummyConstrainedSyntheticTestFunction(constraint_noise_std=[0.1, 0.2]) + + class TestAckley( BotorchTestCase, BaseTestProblemTestCaseMixIn, SyntheticTestFunctionTestCaseMixin ): @@ -329,6 +358,9 @@ class TestConstrainedGramacy( functions = [ ConstrainedGramacy(), + ConstrainedGramacy(negate=True), + ConstrainedGramacy(noise_std=0.1, negate=True), + ConstrainedGramacy(noise_std=0.1, constraint_noise_std=[0.1, 0.2], negate=True), ] @@ -341,6 +373,10 @@ class TestConstrainedHartmann( functions = [ ConstrainedHartmann(dim=6, negate=True), + ConstrainedHartmann(noise_std=0.1, dim=6, negate=True), + ConstrainedHartmann( + noise_std=0.1, constraint_noise_std=0.2, dim=6, negate=True + ), ] @@ -353,6 +389,9 @@ class TestConstrainedHartmannSmooth( functions = [ ConstrainedHartmannSmooth(dim=6, negate=True), + ConstrainedHartmannSmooth( + dim=6, noise_std=0.1, constraint_noise_std=0.2, negate=True + ), ] @@ -362,7 +401,13 @@ class TestPressureVessel( ConstrainedTestProblemTestCaseMixin, ): - functions = [PressureVessel()] + functions = [ + PressureVessel(), + PressureVessel(noise_std=0.1, constraint_noise_std=0.1, negate=True), + PressureVessel( + noise_std=0.1, constraint_noise_std=[0.1, 0.2, 0.1, 0.2], negate=True + ), + ] class TestSpeedReducer( @@ -371,7 +416,11 @@ class TestSpeedReducer( ConstrainedTestProblemTestCaseMixin, ): - functions = [SpeedReducer()] + functions = [ + SpeedReducer(), + SpeedReducer(noise_std=0.1, constraint_noise_std=0.1, negate=True), + SpeedReducer(noise_std=0.1, constraint_noise_std=[0.1] * 11, negate=True), + ] class TestTensionCompressionString( @@ -380,7 +429,12 @@ class TestTensionCompressionString( ConstrainedTestProblemTestCaseMixin, ): - functions = [TensionCompressionString()] + functions = [ + TensionCompressionString(), + TensionCompressionString( + noise_std=0.1, constraint_noise_std=[0.1, 0.2, 0.3, 0.4] + ), + ] class TestWeldedBeamSO( @@ -389,4 +443,7 @@ class TestWeldedBeamSO( ConstrainedTestProblemTestCaseMixin, ): - functions = [WeldedBeamSO()] + functions = [ + WeldedBeamSO(), + WeldedBeamSO(noise_std=0.1, constraint_noise_std=[0.2] * 6), + ]