diff --git a/botorch/optim/optimize.py b/botorch/optim/optimize.py index dd01031663..626885e4bc 100644 --- a/botorch/optim/optimize.py +++ b/botorch/optim/optimize.py @@ -10,15 +10,18 @@ from __future__ import annotations +import warnings + from typing import Any, Callable, Dict, List, Optional, Tuple, Union +import numpy as np import torch from botorch.acquisition.acquisition import ( AcquisitionFunction, OneShotAcquisitionFunction, ) from botorch.acquisition.knowledge_gradient import qKnowledgeGradient -from botorch.exceptions import InputDataError, UnsupportedError +from botorch.exceptions import InputDataError, OptimizationWarning, UnsupportedError from botorch.generation.gen import gen_candidates_scipy from botorch.logging import logger from botorch.optim.initializers import ( @@ -26,8 +29,10 @@ gen_one_shot_kg_initial_conditions, ) from botorch.optim.stopping import ExpMAStoppingCriterion +from scipy.optimize import linprog from torch import Tensor + INIT_OPTION_KEYS = { # set of options for initialization that we should # not pass to scipy.optimize.minimize to avoid @@ -62,6 +67,7 @@ def optimize_acqf( batch_initial_conditions: Optional[Tensor] = None, return_best_only: bool = True, sequential: bool = False, + validate_constraints: bool = True, **kwargs: Any, ) -> Tuple[Tensor, Tensor]: r"""Generate a set of candidates via multi-start optimization. @@ -75,10 +81,10 @@ def optimize_acqf( raw_samples: The number of samples for initialization. This is required if `batch_initial_conditions` is not specified. options: Options for candidate generation. - inequality constraints: A list of tuples (indices, coefficients, rhs), + inequality_constraints: A list of tuples (indices, coefficients, rhs), with each tuple encoding an inequality constraint of the form `\sum_i (X[indices[i]] * coefficients[i]) >= rhs` - equality constraints: A list of tuples (indices, coefficients, rhs), + equality_constraints: A list of tuples (indices, coefficients, rhs), with each tuple encoding an inequality constraint of the form `\sum_i (X[indices[i]] * coefficients[i]) = rhs` nonlinear_inequality_constraints: A list of callables with that represent @@ -100,6 +106,8 @@ def optimize_acqf( random restart initializations of the optimization. sequential: If False, uses joint optimization, otherwise uses sequential optimization. + validate_constraints: If True, validate that the constraint set is + non-empty and bounded by solving a Linear Program. kwargs: Additonal keyword arguments. Returns: @@ -125,9 +133,11 @@ def optimize_acqf( >>> qEI, bounds, 3, 15, 256, sequential=True >>> ) """ - if not (bounds.ndim == 2 and bounds.shape[0] == 2): - raise ValueError( - f"bounds should be a `2 x d` tensor, current shape: {list(bounds.shape)}." + if validate_constraints: + _validate_constraints( + bounds=bounds, + inequality_constraints=inequality_constraints, + equality_constraints=equality_constraints, ) if sequential and q > 1: @@ -158,6 +168,7 @@ def optimize_acqf( batch_initial_conditions=None, return_best_only=True, sequential=False, + validate_constraints=False, ) candidate_list.append(candidate) acq_value_list.append(acq_value) @@ -267,6 +278,7 @@ def optimize_acqf_cyclic( post_processing_func: Optional[Callable[[Tensor], Tensor]] = None, batch_initial_conditions: Optional[Tensor] = None, cyclic_options: Optional[Dict[str, Union[bool, float, int, str]]] = None, + validate_constraints: bool = True, ) -> Tuple[Tensor, Tensor]: r"""Generate a set of `q` candidates via cyclic optimization. @@ -294,6 +306,8 @@ def optimize_acqf_cyclic( If no initial conditions are provided, the default initialization will be used. cyclic_options: Options for stopping criterion for outer cyclic optimization. + validate_constraints: If True, validate that the constraint set is + non-empty and bounded by solving a Linear Program. Returns: A two-element tuple containing @@ -328,6 +342,7 @@ def optimize_acqf_cyclic( batch_initial_conditions=batch_initial_conditions, return_best_only=True, sequential=True, + validate_constraints=validate_constraints, ) if q > 1: cyclic_options = cyclic_options or {} @@ -358,6 +373,7 @@ def optimize_acqf_cyclic( batch_initial_conditions=candidates[i].unsqueeze(0), return_best_only=True, sequential=True, + validate_constraints=False, ) candidates[i] = candidate_i acq_vals[i] = acq_val_i @@ -377,6 +393,7 @@ def optimize_acqf_list( equality_constraints: Optional[List[Tuple[Tensor, Tensor, float]]] = None, fixed_features: Optional[Dict[int, float]] = None, post_processing_func: Optional[Callable[[Tensor], Tensor]] = None, + validate_constraints: bool = True, ) -> Tuple[Tensor, Tensor]: r"""Generate a list of candidates from a list of acquisition functions. @@ -402,6 +419,8 @@ def optimize_acqf_list( post_processing_func: A function that post-processes an optimization result appropriately (i.e., according to `round-trip` transformations). + validate_constraints: If True, validate that the constraint set is + non-empty and bounded by solving a Linear Program. Returns: A two-element tuple containing @@ -413,6 +432,13 @@ def optimize_acqf_list( """ if not acq_function_list: raise ValueError("acq_function_list must be non-empty.") + if validate_constraints: + _validate_constraints( + bounds=bounds, + inequality_constraints=inequality_constraints, + equality_constraints=equality_constraints, + ) + candidate_list, acq_value_list = [], [] candidates = torch.tensor([], device=bounds.device, dtype=bounds.dtype) base_X_pending = acq_function_list[0].X_pending @@ -436,6 +462,7 @@ def optimize_acqf_list( post_processing_func=post_processing_func, return_best_only=True, sequential=False, + validate_constraints=False, ) candidate_list.append(candidate) acq_value_list.append(acq_value) @@ -455,6 +482,7 @@ def optimize_acqf_mixed( equality_constraints: Optional[List[Tuple[Tensor, Tensor, float]]] = None, post_processing_func: Optional[Callable[[Tensor], Tensor]] = None, batch_initial_conditions: Optional[Tensor] = None, + validate_constraints: bool = True, **kwargs: Any, ) -> Tuple[Tensor, Tensor]: r"""Optimize over a list of fixed_features and returns the best solution. @@ -485,6 +513,8 @@ def optimize_acqf_mixed( transformations). batch_initial_conditions: A tensor to specify the initial conditions. Set this if you do not want to use default initialization strategy. + validate_constraints: If True, validate that the constraint set is + non-empty and bounded by solving a Linear Program. Returns: A two-element tuple containing @@ -502,6 +532,12 @@ def optimize_acqf_mixed( "are currently not supported when `q > 1`. This is needed to " "compute the joint acquisition value." ) + if validate_constraints: + _validate_constraints( + bounds=bounds, + inequality_constraints=inequality_constraints, + equality_constraints=equality_constraints, + ) if q == 1: ff_candidate_list, ff_acq_value_list = [], [] @@ -519,6 +555,7 @@ def optimize_acqf_mixed( post_processing_func=post_processing_func, batch_initial_conditions=batch_initial_conditions, return_best_only=True, + validate_constraints=False, ) ff_candidate_list.append(candidate) ff_acq_value_list.append(acq_value) @@ -707,6 +744,105 @@ def _gen_batch_initial_conditions_local_search( raise RuntimeError(f"Failed to generate at least {min_points} initial conditions") +def _validate_constraints( + bounds: Tensor, + inequality_constraints: Optional[List[Tuple[Tensor, Tensor, float]]] = None, + equality_constraints: Optional[List[Tuple[Tensor, Tensor, float]]] = None, +) -> None: + r"""Validate constraints for acquisition function optimization. + + Checks that the constraints define a bounded, non-empty polytope. + + Args: + bounds: A `2 x d` tensor of lower and upper bounds for each column of `X`. + If there are no box constraints, bounds should be an empty `0 x d`-dim + tensor. + inequality constraints: A list of tuples (indices, coefficients, rhs), + with each tuple encoding an inequality constraint of the form + `\sum_i (X[indices[i]] * coefficients[i]) >= rhs` + equality constraints: A list of tuples (indices, coefficients, rhs), + with each tuple encoding an inequality constraint of the form + `\sum_i (X[indices[i]] * coefficients[i]) = rhs` + """ + # We solve the following Linear Program to ensure that he constraint set + # is non-empty and bounded: + # + # max_x |x|_1 + # s.t. bounds(x) + # inequality_constraints(x) + # equality_constraints(x) + # + # To do this we can introduce auxiliary variables s and solve the + # following standard formulation: + # + # min_(x, s) - sum_i(s_i) + # s.t. -x <= s <= x + # bounds(x) + # inequality_constraints(x) + # equality_constraints(x) + # + if bounds.numel() == 0: + if inequality_constraints is None: + raise UnsupportedError( + "Must provide either `bounds` or `inequality_constraints` (or both)." + ) + elif not (bounds.ndim == 2 and bounds.shape[0] == 2): + raise ValueError( + f"bounds should be a `2 x d` tensor, current shape: {tuple(bounds.shape)}." + ) + d = bounds.shape[-1] + bounds_lp, A_ub, b_ub, A_eq, b_eq = None, None, None, None, None + # The first `d` variables are `x`, the last `d` are the auxiliary `s` + if bounds.numel() > 0: + # `s` is unbounded + bounds_lp = [tuple(b_i) for b_i in bounds.t()] + [(None, None)] * d + # Encode the constraint `-x <= s <= x` + A_ub = np.zeros((2 * d, 2 * d)) + b_ub = np.zeros(2 * d) + A_ub[:d, :d] = -1.0 + A_ub[:d, d : 2 * d] = -1.0 + A_ub[d : 2 * d, :d] = -1.0 + A_ub[d : 2 * d, d : 2 * d] = 1.0 + # Convet and add additional inequality constraints if present + if inequality_constraints is not None: + A_ineq = np.zeros((len(inequality_constraints), 2 * d)) + b_ineq = np.zeros(len(inequality_constraints)) + for i, (indices, coefficients, rhs) in enumerate(inequality_constraints): + A_ineq[i, indices] = -coefficients + b_ineq[i] = -rhs + A_ub = np.concatenate((A_ub, A_ineq)) + b_ub = np.concatenate((b_ub, b_ineq)) + # Convert equality constraints if present + if equality_constraints is not None: + A_eq = np.zeros((len(equality_constraints), 2 * d)) + b_eq = np.zeros(len(equality_constraints)) + for i, (indices, coefficients, rhs) in enumerate(equality_constraints): + A_eq[i, indices] = coefficients + b_eq[i] = rhs + # Objective is `- sum_i s_i` (note: the `s_i` are guaranteed to be positive) + c = np.concatenate((np.zeros(d), -np.ones(d))) + # Solve the problem + result = linprog( + c=c, + bounds=bounds_lp, + A_ub=A_ub, + b_ub=b_ub, + A_eq=A_eq, + b_eq=b_eq, + ) + # Check what's going on if unsuccessful + if not result.success: + if result.status == 2: + raise ValueError("Feasible set non-empty. Check your constraints.") + if result.status == 3: + raise ValueError("Feasible set unbounded.") + warnings.warn( + "Ran into issues when checking for boundedness of feasible set. " + f"Optimizer message: {result.message}.", + OptimizationWarning, + ) + + def optimize_acqf_discrete_local_search( acq_function: AcquisitionFunction, discrete_choices: List[Tensor], diff --git a/test/optim/test_optimize.py b/test/optim/test_optimize.py index f7a11f7856..90f0992122 100644 --- a/test/optim/test_optimize.py +++ b/test/optim/test_optimize.py @@ -5,20 +5,24 @@ # LICENSE file in the root directory of this source tree. import itertools + +import warnings from unittest import mock import numpy as np import torch +from botorch import settings from botorch.acquisition.acquisition import ( AcquisitionFunction, OneShotAcquisitionFunction, ) -from botorch.exceptions import InputDataError, UnsupportedError +from botorch.exceptions import InputDataError, OptimizationWarning, UnsupportedError from botorch.optim.optimize import ( _filter_infeasible, _filter_invalid, _gen_batch_initial_conditions_local_search, _generate_neighbors, + _validate_constraints, optimize_acqf, optimize_acqf_cyclic, optimize_acqf_discrete, @@ -72,6 +76,76 @@ def rounding_func(X: Tensor) -> Tensor: class TestOptimizeAcqf(BotorchTestCase): + def test_validate_constraints(self): + for dtype in (torch.float, torch.double): + tkwargs = {"device": self.device, "dtype": dtype} + with self.assertRaisesRegex( + UnsupportedError, "Must provide either `bounds` or `inequality_constraints`" + ): + _validate_constraints(bounds=torch.empty(0, 2, **tkwargs)) + with self.assertRaisesRegex( + ValueError, r"bounds should be a `2 x d` tensor, current shape: \(3, 2\)." + ): + _validate_constraints(bounds=torch.zeros(3, 2), inequality_constraints=[]) + # Check standard box bounds + bounds = torch.stack((torch.zeros(2, **tkwargs), torch.ones(2, **tkwargs))) + _validate_constraints(bounds=bounds) + # Check failure on empty box + with self.assertRaisesRegex( + ValueError, "Feasible set non-empty. Check your constraints." + ): + _validate_constraints(bounds=bounds.flip(0)) + # Check failure on unbounded "box" + bounds[1, 1] = float("inf") + with self.assertRaisesRegex(ValueError, "Feasible set unbounded."): + _validate_constraints(bounds=bounds) + # Check that added inequality constraint resolve this + _validate_constraints( + bounds=bounds, + inequality_constraints=[ + ( + torch.tensor([1], device=self.device), + torch.tensor([-1.0], **tkwargs), + -2.0, + ) + ], + ) + # Check that added equality constraint resolves this + _validate_constraints( + bounds=bounds, + equality_constraints=[ + ( + torch.tensor([0, 1], device=self.device), + torch.tensor([1.0, -1.0], **tkwargs), + 0.0, + ) + ], + ) + # Check that inequality constraints alone work + zero = torch.tensor([0], device=self.device) + one = torch.tensor([1], device=self.device) + inequality_constraints = [ + (zero, torch.tensor([1.0], **tkwargs), 0.0), + (zero, torch.tensor([-1.0], **tkwargs), -1.0), + (one, torch.tensor([1.0], **tkwargs), 0.0), + (one, torch.tensor([-1.0], **tkwargs), -1.0), + ] + _validate_constraints( + bounds=bounds, inequality_constraints=inequality_constraints + ) + # Check that other messages are surfaced as warnings + bounds = torch.stack((torch.zeros(2, **tkwargs), torch.ones(2, **tkwargs))) + mock_result = OptimizeResult(success=False, status=-1, message="foo") + with mock.patch("botorch.optim.optimize.linprog", return_value=mock_result): + with warnings.catch_warnings(record=True) as ws, settings.debug(True): + _validate_constraints(bounds=bounds) + self.assertTrue(any(issubclass(w.category, OptimizationWarning)) for w in ws) + expected_msg = ( + "Ran into issues when checking for boundedness of feasible set. " + "Optimizer message: foo." + ) + self.assertTrue(any(expected_msg in str(w.message) for w in ws)) + @mock.patch("botorch.optim.optimize.gen_batch_initial_conditions") @mock.patch("botorch.optim.optimize.gen_candidates_scipy") def test_optimize_acqf_joint( @@ -589,11 +663,19 @@ def test_optimize_acqf_cyclic(self, mock_optimize_acqf): if i == 0: # first cycle expected_call_args.update( - {"batch_initial_conditions": None, "q": q} + { + "batch_initial_conditions": None, + "q": q, + "validate_constraints": True, + } ) else: expected_call_args.update( - {"batch_initial_conditions": orig_candidates[i - 1 : i], "q": 1} + { + "batch_initial_conditions": orig_candidates[i - 1 : i], + "q": 1, + "validate_constraints": False, + } ) orig_candidates[i - 1] = candidate_rvs[i] for k, v in call_args_list[i][1].items(): @@ -615,9 +697,6 @@ def test_optimize_acqf_list(self, mock_optimize_acqf): options = {} tkwargs = {"device": self.device} bounds = torch.stack([torch.zeros(3), 4 * torch.ones(3)]) - inequality_constraints = [ - [torch.tensor([3]), torch.tensor([4]), torch.tensor(5)] - ] # reinitialize so that dtype mock_acq_function_1 = MockAcquisitionFunction() mock_acq_function_2 = MockAcquisitionFunction() @@ -627,8 +706,8 @@ def test_optimize_acqf_list(self, mock_optimize_acqf): # clear previous X_pending m.set_X_pending(None) tkwargs["dtype"] = dtype - inequality_constraints[0] = [ - t.to(**tkwargs) for t in inequality_constraints[0] + inequality_constraints = [ + [torch.tensor([3]), torch.tensor([4.0], **tkwargs), 5.0] ] mock_optimize_acqf.reset_mock() bounds = bounds.to(**tkwargs) @@ -701,6 +780,7 @@ def test_optimize_acqf_list(self, mock_optimize_acqf): "batch_initial_conditions": None, "return_best_only": True, "sequential": False, + "validate_constraints": False, } for i in range(len(call_args_list)): expected_call_args["acq_function"] = mock_acq_function_list[i] @@ -781,6 +861,7 @@ def test_optimize_acqf_mixed_q1(self, mock_optimize_acqf): "batch_initial_conditions": None, "return_best_only": True, "sequential": False, + "validate_constraints": False, } for i in range(len(call_args_list)): expected_call_args["fixed_features"] = fixed_features_list[i]