From 34ce0780265777c7773de379496d92e7ca86257e Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Tue, 22 Oct 2024 12:17:57 +0200 Subject: [PATCH 01/47] Introduce class for continuous interpoint constraints --- baybe/constraints/__init__.py | 2 + baybe/constraints/continuous.py | 69 ++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/baybe/constraints/__init__.py b/baybe/constraints/__init__.py index 8b92ecd6f..0ca48afa2 100644 --- a/baybe/constraints/__init__.py +++ b/baybe/constraints/__init__.py @@ -4,6 +4,7 @@ from baybe.constraints.continuous import ( ContinuousCardinalityConstraint, ContinuousLinearConstraint, + ContinuousLinearInterPointConstraint, ) from baybe.constraints.deprecation import ( ContinuousLinearEqualityConstraint, @@ -32,6 +33,7 @@ "ContinuousCardinalityConstraint", "ContinuousLinearEqualityConstraint", "ContinuousLinearInequalityConstraint", + "ContinuousLinearInterPointConstraint", # --- Discrete constraints ---# "DiscreteCardinalityConstraint", "DiscreteCustomConstraint", diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py index d5d5be47e..e9a8fb6a9 100644 --- a/baybe/constraints/continuous.py +++ b/baybe/constraints/continuous.py @@ -5,11 +5,13 @@ import gc import math from collections.abc import Collection, Sequence +from itertools import chain, repeat from typing import TYPE_CHECKING, Any import numpy as np from attr.validators import in_ from attrs import define, field +from typing_extensions import override from baybe.constraints.base import ( CardinalityConstraint, @@ -98,7 +100,10 @@ def _drop_parameters( ) def to_botorch( - self, parameters: Sequence[NumericalContinuousParameter], idx_offset: int = 0 + self, + parameters: Sequence[NumericalContinuousParameter], + idx_offset: int = 0, + batch_size: int = 1, ) -> tuple[Tensor, Tensor, float]: """Cast the constraint in a format required by botorch. @@ -108,6 +113,8 @@ def to_botorch( Args: parameters: The parameter objects of the continuous space. idx_offset: Offset to the provided parameter indices. + batch_size: the batch size used in the recommendation. Necessary for + interpoint constraints, ignored by all others. Returns: The tuple required by botorch. @@ -132,6 +139,66 @@ def to_botorch( ) +@define(frozen=True) +class ContinuousLinearInterPointConstraint(ContinuousLinearConstraint): + """Class for inter-point constraints. + + An inter-point constraint is a constraint that is defined over full batches. That + is, and inter-point constraint of the form ``param_1 + 2*param_2 <=2`` means that + the sum of ``param2`` plus two times the sum of ``param_2`` across the full batch + must not exceed 2. + """ + + eval_during_creation = False + eval_during_modeling = True + numerical_only = True + + @override + def to_botorch( + self, + parameters: Sequence[NumericalContinuousParameter], + idx_offset: int = 0, + batch_size: int = 1, + ) -> tuple[Tensor, Tensor, float]: + """Cast the constraint in a format required by botorch. + + Used in calling ``optimize_acqf_*`` functions, for details see + https://botorch.org/api/optim.html#botorch.optim.optimize.optimize_acqf + + Args: + parameters: The parameter objects of the continuous space. + idx_offset: Offset to the provided parameter indices. + batch_size: The size of the batch for which the constraint is applied. + + Raises: + ValueError: If ``batch_size`` is smaller than 1. + + Returns: + The tuple required by botorch. + """ + if batch_size < 1: + raise ValueError(f"Batch size must be at least 1 but is {batch_size}.") + + import torch + + from baybe.utils.torch import DTypeFloatTorch + + param_names = [p.name for p in parameters] + # Get the indices of the parameters used in the constraint + param_index = {name: param_names.index(name) for name in self.parameters} + param_indices = [ + (batch, param_index[name] + idx_offset) + for name in self.parameters + for batch in range(batch_size) + ] + coefficients = list(chain(*zip(*repeat(self.coefficients, batch_size)))) + return ( + torch.tensor(param_indices), + torch.tensor(coefficients, dtype=DTypeFloatTorch), + np.asarray(self.rhs, dtype=DTypeFloatNumpy).item(), + ) + + @define class ContinuousCardinalityConstraint( CardinalityConstraint, ContinuousNonlinearConstraint From fac5953eec247aa23c596ffbcc9d78ddf1c8f3f3 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Tue, 22 Oct 2024 12:49:39 +0200 Subject: [PATCH 02/47] Add interpoint constraints to search space --- baybe/searchspace/continuous.py | 97 ++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index 5fedf3f4e..b988acd63 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -16,6 +16,7 @@ from baybe.constraints import ( ContinuousCardinalityConstraint, ContinuousLinearConstraint, + ContinuousLinearInterPointConstraint, ) from baybe.constraints.base import ContinuousConstraint, ContinuousNonlinearConstraint from baybe.constraints.validation import ( @@ -69,6 +70,16 @@ class SubspaceContinuous(SerialMixin): ) """Nonlinear constraints.""" + constraints_ip_lin_eq: tuple[ContinuousLinearInterPointConstraint, ...] = field( + converter=to_tuple, factory=tuple + ) + "Linear interpoint equality constraints." + + constraints_ip_lin_ineq: tuple[ContinuousLinearInterPointConstraint, ...] = field( + converter=to_tuple, factory=tuple + ) + "Linear interpoint inequality constraints." + @override def __str__(self) -> str: if self.is_empty: @@ -83,9 +94,21 @@ def __str__(self) -> str: nonlin_constraints_list = [ constr.summary() for constr in self.constraints_nonlin ] + nonlin_constraints_list = [ + constr.summary() for constr in self.constraints_nonlin + ] + ip_eq_constraints_list = [ + constr.summary() for constr in self.constraints_ip_lin_eq + ] + ip_ineq_constraints_list = [ + constr.summary() for constr in self.constraints_ip_lin_ineq + ] + param_df = pd.DataFrame(param_list) lin_eq_df = pd.DataFrame(eq_constraints_list) lin_ineq_df = pd.DataFrame(ineq_constraints_list) + ip_lin_eq_df = pd.DataFrame(ip_eq_constraints_list) + ip_lin_ineq_df = pd.DataFrame(ip_ineq_constraints_list) nonlinear_df = pd.DataFrame(nonlin_constraints_list) fields = [ @@ -94,6 +117,14 @@ def __str__(self) -> str: ), to_string("Linear Equality Constraints", pretty_print_df(lin_eq_df)), to_string("Linear Inequality Constraints", pretty_print_df(lin_ineq_df)), + to_string( + "Linear Interpoint Equality Constraints", + pretty_print_df(ip_lin_eq_df), + ), + to_string( + "Linear Interpoint Inequality Constraints", + pretty_print_df(ip_lin_ineq_df), + ), to_string("Non-linear Constraints", pretty_print_df(nonlinear_df)), ] @@ -134,6 +165,32 @@ def _validate_constraints_lin_ineq( f"the 'operator' for all list items should be '>=' or '<='." ) + @constraints_ip_lin_eq.validator + def _validate_constraints_ip_lin_eq( + self, _, lst: list[ContinuousLinearInterPointConstraint] + ) -> None: + """Validate linear interpoint equality constraints.""" + # TODO Remove once eq and ineq constraints are consolidated into one list + if not all(c.is_eq for c in lst): + raise ValueError( + f"The list '{fields(self.__class__).constraints_ip_lin_eq.name}' of " + f"{self.__class__.__name__} only accepts equality constraints, i.e. " + f"the 'operator' for all list items should be '='." + ) + + @constraints_ip_lin_ineq.validator + def _validate_constraints_ip_lin_ineq( + self, _, lst: list[ContinuousLinearConstraint] + ) -> None: + """Validate linear interpoint inequality constraints.""" + # TODO Remove once eq and ineq constraints are consolidated into one list + if any(c.is_eq for c in lst): + raise ValueError( + f"The list '{fields(self.__class__).constraints_ip_lin_ineq.name}' of " + f"{self.__class__.__name__} only accepts inequality constraints, i.e. " + f"the 'operator' for all list items should be '>=' or '<='." + ) + @constraints_nonlin.validator def _validate_constraints_nonlin(self, _, __) -> None: """Validate nonlinear constraints.""" @@ -178,12 +235,30 @@ def from_product( constraints_lin_eq=[ # type:ignore[attr-misc] c for c in constraints - if (isinstance(c, ContinuousLinearConstraint) and c.is_eq) + if ( + isinstance(c, ContinuousLinearConstraint) + and not isinstance(c, ContinuousLinearInterPointConstraint) + and c.is_eq + ) ], constraints_lin_ineq=[ # type:ignore[attr-misc] c for c in constraints - if (isinstance(c, ContinuousLinearConstraint) and not c.is_eq) + if ( + isinstance(c, ContinuousLinearConstraint) + and not isinstance(c, ContinuousLinearInterPointConstraint) + and not c.is_eq + ) + ], + constraints_ip_lin_eq=[ # type:ignore[misc] + c + for c in constraints + if (isinstance(c, ContinuousLinearInterPointConstraint) and c.is_eq) + ], + constraints_ip_lin_ineq=[ # type:ignore[misc] + c + for c in constraints + if (isinstance(c, ContinuousLinearInterPointConstraint) and not c.is_eq) ], constraints_nonlin=[ # type:ignore[attr-misc] c for c in constraints if isinstance(c, ContinuousNonlinearConstraint) @@ -286,6 +361,24 @@ def comp_rep_bounds(self) -> pd.DataFrame: dtype=DTypeFloatNumpy, ) + @property + def is_constrained(self) -> bool: + """Return whether the subspace is constrained in any way.""" + return any( + ( + self.constraints_ip_lin_eq, + self.constraints_ip_lin_ineq, + self.constraints_lin_eq, + self.constraints_lin_ineq, + self.constraints_nonlin, + ) + ) + + @property + def has_interpoint_constraints(self) -> bool: + """Return whether or not the space has any interpoint constraints.""" + return any((self.constraints_ip_lin_eq, self.constraints_ip_lin_ineq)) + def _drop_parameters(self, parameter_names: Collection[str]) -> SubspaceContinuous: """Create a copy of the subspace with certain parameters removed. From ffd6479d8cc2e37b3b4029fcb05c43a7eb80fe40 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Tue, 22 Oct 2024 12:51:03 +0200 Subject: [PATCH 03/47] Adjust sampling for interpoint constraints --- baybe/searchspace/continuous.py | 88 ++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index b988acd63..47d8dfa9e 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -5,7 +5,7 @@ import gc import warnings from collections.abc import Collection, Sequence -from itertools import chain +from itertools import chain, repeat from typing import TYPE_CHECKING, Any, cast import numpy as np @@ -484,14 +484,17 @@ def sample_uniform(self, batch_size: int = 1) -> pd.DataFrame: if not self.parameters: return pd.DataFrame(index=pd.RangeIndex(0, batch_size)) - - if ( - len(self.constraints_lin_eq) == 0 - and len(self.constraints_lin_ineq) == 0 - and len(self.constraints_cardinality) == 0 - ): + # If the space is completely unconstrained, we can sample from bounds. + if not self.is_constrained: return self._sample_from_bounds(batch_size, self.comp_rep_bounds.values) + if self.has_interpoint_constraints: + return self._sample_from_polytope_with_interpoint_constraints( + batch_size, self.comp_rep_bounds.values + ) + + # If there are neither cardinality nor interpoint constraints, we sample + # directly from the polytope if len(self.constraints_cardinality) == 0: return self._sample_from_polytope(batch_size, self.comp_rep_bounds.values) @@ -505,6 +508,77 @@ def _sample_from_bounds(self, batch_size: int, bounds: np.ndarray) -> pd.DataFra return pd.DataFrame(points, columns=self.parameter_names) + def _sample_from_polytope_with_interpoint_constraints( + self, + batch_size: int, + bounds: np.ndarray, + ) -> pd.DataFrame: + """Draw uniform random samples from a polytope with interpoint constraints.""" + # If the space has interpoint constraints, we need to sample from a larger + # searchspace that models the batch size via additional dimension. This is + # necessary since `get_polytope_samples` cannot handle inter-point constraints, + # see https://github.com/pytorch/botorch/issues/2468 + + import torch + from botorch.utils.sampling import get_polytope_samples + + from baybe.utils.numerical import DTypeFloatNumpy + from baybe.utils.torch import DTypeFloatTorch + + # The number of parameters is needed at some places for adjusting indices + num_of_params = len(self.parameters) + + eq_constraints, ineq_constraints = [], [] + + # We start with the general constraints before going to interpoint constraints + for c in [*self.constraints_lin_eq, *self.constraints_lin_ineq]: + param_indices, coefficients, rhs = c.to_botorch(self.parameters) + for b in range(batch_size): + botorch_tuple = (param_indices + b * num_of_params, coefficients, rhs) + if c.is_eq: + eq_constraints.append(botorch_tuple) + else: + ineq_constraints.append(botorch_tuple) + + if self.has_interpoint_constraints: + for c in [ + *self.constraints_ip_lin_eq, + *self.constraints_ip_lin_ineq, + ]: + # Get the indices of the parameters used in the constraint + param_index = { + name: self.parameter_names.index(name) for name in c.parameters + } + param_indices_list = [ + batch * num_of_params + param_index[param] + for param in c.parameters + for batch in range(batch_size) + ] + coefficients_list = list( + chain(*zip(*repeat(c.coefficients, batch_size))) + ) + botorch_tuple = ( + torch.tensor(param_indices_list), + torch.tensor(coefficients_list, dtype=DTypeFloatTorch), + np.asarray(c.rhs, dtype=DTypeFloatNumpy).item(), + ) + if c.is_eq: + eq_constraints.append(botorch_tuple) + else: + ineq_constraints.append(botorch_tuple) + + bounds_joint = torch.cat( + [torch.from_numpy(bounds) for _ in range(batch_size)], dim=-1 + ) + points = get_polytope_samples( + n=1, + bounds=bounds_joint, + equality_constraints=eq_constraints, + inequality_constraints=ineq_constraints, + ) + points = points.reshape(batch_size, points.shape[-1] // batch_size) + return pd.DataFrame(points, columns=self.parameter_names) + def _sample_from_polytope( self, batch_size: int, bounds: np.ndarray ) -> pd.DataFrame: From 3200258c46fc34de769163617677aeb2a1cdfdac Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Tue, 22 Oct 2024 12:19:58 +0200 Subject: [PATCH 04/47] Use interpoint constraints in BotorchRecommender --- baybe/recommenders/pure/bayesian/botorch.py | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py index 3b1c0de96..96cded5e3 100644 --- a/baybe/recommenders/pure/bayesian/botorch.py +++ b/baybe/recommenders/pure/bayesian/botorch.py @@ -11,6 +11,9 @@ from typing_extensions import override from baybe.acquisition.acqfs import qThompsonSampling +from baybe.constraints import ( + ContinuousLinearInterPointConstraint, +) from baybe.exceptions import ( IncompatibilityError, IncompatibleAcquisitionFunctionError, @@ -178,6 +181,25 @@ def _recommend_continuous( import torch from botorch.optim import optimize_acqf + interpoint_constraints_lin_eq = ( + [ + c.to_botorch(subspace_continuous.parameters, batch_size=batch_size) + for c in subspace_continuous.constraints_ip_lin_eq + if isinstance(c, ContinuousLinearInterPointConstraint) and c.is_eq + ] + if subspace_continuous.has_interpoint_constraints + else [] + ) + interpoint_constraints_lin_ineq = ( + [ + c.to_botorch(subspace_continuous.parameters, batch_size=batch_size) + for c in subspace_continuous.constraints_ip_lin_ineq + if isinstance(c, ContinuousLinearInterPointConstraint) and not c.is_eq + ] + if subspace_continuous.has_interpoint_constraints + else [] + ) + points, _ = optimize_acqf( acq_function=self._botorch_acqf, bounds=torch.from_numpy(subspace_continuous.comp_rep_bounds.values), @@ -188,11 +210,13 @@ def _recommend_continuous( c.to_botorch(subspace_continuous.parameters) for c in subspace_continuous.constraints_lin_eq ] + + interpoint_constraints_lin_eq or None, # TODO: https://github.com/pytorch/botorch/issues/2042 inequality_constraints=[ c.to_botorch(subspace_continuous.parameters) for c in subspace_continuous.constraints_lin_ineq ] + + interpoint_constraints_lin_ineq or None, # TODO: https://github.com/pytorch/botorch/issues/2042 sequential=self.sequential_continuous, ) @@ -234,6 +258,8 @@ def _recommend_hybrid( Returns: The recommended points. """ + # TODO Interpoint constraints are not yet enabled in hybrid search spaces + # For batch size > 1, this optimizer needs a MC acquisition function if batch_size > 1 and not self.acquisition_function.is_mc: raise IncompatibleAcquisitionFunctionError( From 9a2d417553a8c1021b73de3259fd6536eee0fc31 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Tue, 22 Oct 2024 12:21:09 +0200 Subject: [PATCH 05/47] Include interpoint constraints in example --- .../linear_constraints.py | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/examples/Constraints_Continuous/linear_constraints.py b/examples/Constraints_Continuous/linear_constraints.py index 7eba01d56..2e42f0729 100644 --- a/examples/Constraints_Continuous/linear_constraints.py +++ b/examples/Constraints_Continuous/linear_constraints.py @@ -18,7 +18,10 @@ from botorch.test_functions import Rastrigin from baybe import Campaign -from baybe.constraints import ContinuousLinearConstraint +from baybe.constraints import ( + ContinuousLinearConstraint, + ContinuousLinearInterPointConstraint, +) from baybe.parameters import NumericalContinuousParameter from baybe.searchspace import SearchSpace from baybe.targets import NumericalTarget @@ -139,3 +142,61 @@ "2.0*x_2 + 3.0*x_4 <= 1.0 satisfied in all recommendations? ", (2.0 * measurements["x_2"] + 3.0 * measurements["x_4"]).le(1.0 + TOLERANCE).all(), ) + + +### Using inter-point constraints + +# It is also possible to require inter-point constraints which constraint the value of +# a single parameter across a full batch. +# Since these constraints require information about the batch size, they are not used +# during the creation of the search space but handed over to the `recommend` call. +# This example models the following inter-point constraints and combines them also +# with regular constraints. +# 1. The sum of `x_1` across all batches needs to be >= 2.5. +# 2. The sum of `x_2` across all batches needs to be exactly 5. +# 3. The sum of `2*x_3` minus the sum of `x_4` across all batches needs to be >= 5. + + +inter_constraints = [ + ContinuousLinearInterPointConstraint( + parameters=["x_1"], + operator=">=", + coefficients=[1], + rhs=2.5, + ), + ContinuousLinearInterPointConstraint( + parameters=["x_2"], operator="=", coefficients=[1], rhs=5 + ), + ContinuousLinearInterPointConstraint( + parameters=["x_3", "x_4"], + operator=">=", + coefficients=[2, -1], + rhs=5, + ), +] + +### Construct search space without the previous constraints + +inter_searchspace = SearchSpace.from_product( + parameters=parameters, constraints=inter_constraints +) + +inter_campaign = Campaign( + searchspace=inter_searchspace, + objective=objective, +) + +for k in range(N_ITERATIONS): + rec = inter_campaign.recommend(batch_size=BATCH_SIZE) + + # target value are looked up via the botorch wrapper + target_values = [] + for index, row in rec.iterrows(): + target_values.append(WRAPPED_FUNCTION(*row.to_list())) + + rec["Target"] = target_values + inter_campaign.add_measurements(rec) + # Check inter-point constraints + assert rec["x_1"].sum() >= 2.5 - TOLERANCE + assert np.isclose(rec["x_2"].sum(), 5) + assert 2 * rec["x_3"].sum() - rec["x_4"].sum() >= 2.5 - TOLERANCE From 95685f33d34b51496f650acd16ca1ffc8db9255f Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Tue, 22 Oct 2024 12:22:24 +0200 Subject: [PATCH 06/47] Include interpoint constraints in tests --- tests/conftest.py | 30 +++++++- .../test_constraints_continuous.py | 75 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index c82630cd4..d7e04852f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,6 +29,7 @@ from baybe.constraints import ( ContinuousCardinalityConstraint, ContinuousLinearConstraint, + ContinuousLinearInterPointConstraint, DiscreteCardinalityConstraint, DiscreteCustomConstraint, DiscreteDependenciesConstraint, @@ -553,6 +554,30 @@ def custom_function(df: pd.DataFrame) -> pd.Series: coefficients=[1.0, 3.0], rhs=0.3, ), + "InterConstraint_1": ContinuousLinearInterPointConstraint( + parameters=["Conti_finite1"], + operator="=", + coefficients=[1], + rhs=0.3, + ), + "InterConstraint_2": ContinuousLinearInterPointConstraint( + parameters=["Conti_finite1"], + operator=">=", + coefficients=[2], + rhs=0.3, + ), + "InterConstraint_3": ContinuousLinearInterPointConstraint( + parameters=["Conti_finite1", "Conti_finite2"], + operator="=", + coefficients=[1, 1], + rhs=0.3, + ), + "InterConstraint_4": ContinuousLinearInterPointConstraint( + parameters=["Conti_finite1", "Conti_finite2"], + coefficients=[2, -1], + operator=">=", + rhs=0.3, + ), } return [ c_item @@ -895,7 +920,10 @@ def fixture_default_onnx_surrogate(onnx_str) -> CustomONNXSurrogate: ), ) def run_iterations( - campaign: Campaign, n_iterations: int, batch_size: int, add_noise: bool = True + campaign: Campaign, + n_iterations: int, + batch_size: int, + add_noise: bool = True, ) -> None: """Run a campaign for some fake iterations. diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py index 46adc4b21..96e04eac3 100644 --- a/tests/constraints/test_constraints_continuous.py +++ b/tests/constraints/test_constraints_continuous.py @@ -68,6 +68,81 @@ def test_inequality3(campaign, n_iterations, batch_size): assert (1.0 * res["Conti_finite1"] + 3.0 * res["Conti_finite2"]).le(0.301).all() +@pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) +@pytest.mark.parametrize("constraint_names", [["InterConstraint_1"]]) +@pytest.mark.parametrize("batch_size", [5], ids=["b5"]) +def test_interpoint_equality_single_parameter(campaign, n_iterations, batch_size): + """Test single parameter inter-point equality constraint.""" + run_iterations(campaign, n_iterations, batch_size, add_noise=False) + res = campaign.measurements + print(res) + + for batch in range(n_iterations): + res_batch = res[res["BatchNr"] == batch + 1] + assert np.isclose(res_batch["Conti_finite1"].sum(), 0.3) + + +@pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) +@pytest.mark.parametrize("constraint_names", [["InterConstraint_2"]]) +@pytest.mark.parametrize("batch_size", [5], ids=["b5"]) +def test_interpoint_inequality_single_parameter(campaign, n_iterations, batch_size): + """Test single parameter inter-point inequality constraint.""" + run_iterations(campaign, n_iterations, batch_size, add_noise=False) + res = campaign.measurements + print(res) + + for batch in range(n_iterations): + res_batch = res[res["BatchNr"] == batch + 1] + assert 2 * res_batch["Conti_finite1"].sum() >= 0.299 + + +@pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) +@pytest.mark.parametrize("constraint_names", [["InterConstraint_3"]]) +@pytest.mark.parametrize("batch_size", [5], ids=["b5"]) +def test_interpoint_equality_multiple_parameters(campaign, n_iterations, batch_size): + """Test inter-point equality constraint involving multiple parameters.""" + run_iterations(campaign, n_iterations, batch_size, add_noise=False) + res = campaign.measurements + print(res) + + for batch in range(n_iterations): + res_batch = res[res["BatchNr"] == batch + 1] + assert np.isclose( + res_batch["Conti_finite1"].sum() + res_batch["Conti_finite2"].sum(), 0.3 + ) + + +@pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) +@pytest.mark.parametrize("constraint_names", [["InterConstraint_4"]]) +@pytest.mark.parametrize("batch_size", [5], ids=["b5"]) +def test_interpoint_inequality_multiple_parameters(campaign, n_iterations, batch_size): + """Test inter-point inequality constraint involving multiple parameters.""" + run_iterations(campaign, n_iterations, batch_size, add_noise=False) + res = campaign.measurements + print(res) + for batch in range(n_iterations): + res_batch = res[res["BatchNr"] == batch + 1] + assert ( + 2 * res_batch["Conti_finite1"].sum() - res_batch["Conti_finite2"].sum() + >= 0.299 + ) + + +@pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) +@pytest.mark.parametrize( + "constraint_names", [["ContiConstraint_4", "InterConstraint_2"]] +) +@pytest.mark.parametrize("batch_size", [5], ids=["b5"]) +def test_interpoint_normal_mix(campaign, n_iterations, batch_size): + """Test mixing interpoint and normal inequality constraints.""" + run_iterations(campaign, n_iterations, batch_size, add_noise=False) + res = campaign.measurements + print(res) + + assert res.at[0, "Conti_finite1"] + 3.0 * res.at[1, "Conti_finite1"] >= 0.299 + assert (1.0 * res["Conti_finite1"] + 3.0 * res["Conti_finite2"]).ge(0.299).all() + + @pytest.mark.slow @pytest.mark.parametrize( "parameter_names", From 37615bbd70cc998660384a8c8866d847dd2b10cf Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Mon, 11 Nov 2024 18:01:01 +0100 Subject: [PATCH 07/47] Simplify definition of inter-point constraints --- baybe/constraints/__init__.py | 2 - baybe/constraints/continuous.py | 100 +++++----------- baybe/recommenders/pure/bayesian/botorch.py | 28 +---- baybe/searchspace/continuous.py | 109 ++++-------------- .../linear_constraints.py | 19 ++- tests/conftest.py | 13 ++- 6 files changed, 67 insertions(+), 204 deletions(-) diff --git a/baybe/constraints/__init__.py b/baybe/constraints/__init__.py index 0ca48afa2..8b92ecd6f 100644 --- a/baybe/constraints/__init__.py +++ b/baybe/constraints/__init__.py @@ -4,7 +4,6 @@ from baybe.constraints.continuous import ( ContinuousCardinalityConstraint, ContinuousLinearConstraint, - ContinuousLinearInterPointConstraint, ) from baybe.constraints.deprecation import ( ContinuousLinearEqualityConstraint, @@ -33,7 +32,6 @@ "ContinuousCardinalityConstraint", "ContinuousLinearEqualityConstraint", "ContinuousLinearInequalityConstraint", - "ContinuousLinearInterPointConstraint", # --- Discrete constraints ---# "DiscreteCardinalityConstraint", "DiscreteCustomConstraint", diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py index e9a8fb6a9..8a1939a03 100644 --- a/baybe/constraints/continuous.py +++ b/baybe/constraints/continuous.py @@ -9,9 +9,8 @@ from typing import TYPE_CHECKING, Any import numpy as np -from attr.validators import in_ +from attr.validators import in_, instance_of from attrs import define, field -from typing_extensions import override from baybe.constraints.base import ( CardinalityConstraint, @@ -47,6 +46,15 @@ class ContinuousLinearConstraint(ContinuousConstraint): rhs: float = field(default=0.0, converter=float, validator=finite_float) """Right-hand side value of the in-/equality.""" + is_interpoint: bool = field(default=False, validator=instance_of(bool)) + """Flag for defining an interpoint constraint. + + An inter-point constraint is a constraint that is defined over full batches. That + is, and inter-point constraint of the form ``param_1 + 2*param_2 <=2`` means that + the sum of ``param2`` plus two times the sum of ``param_2`` across the full batch + must not exceed 2. + """ + @coefficients.validator def _validate_coefficients( # noqa: DOC101, DOC103 self, _: Any, coefficients: list[float] @@ -113,7 +121,7 @@ def to_botorch( Args: parameters: The parameter objects of the continuous space. idx_offset: Offset to the provided parameter indices. - batch_size: the batch size used in the recommendation. Necessary for + batch_size: The batch size used in the recommendation. Necessary for interpoint constraints, ignored by all others. Returns: @@ -124,81 +132,33 @@ def to_botorch( from baybe.utils.torch import DTypeFloatTorch param_names = [p.name for p in parameters] - param_indices = [ - param_names.index(p) + idx_offset - for p in self.parameters - if p in param_names - ] + if not self.is_interpoint: + param_indices = [ + param_names.index(p) + idx_offset + for p in self.parameters + if p in param_names + ] + coefficients = self.coefficients + torch_indices = torch.tensor(param_indices) + else: + param_index = {name: param_names.index(name) for name in self.parameters} + param_indices_interpoint = [ + (batch, param_index[name] + idx_offset) + for name in self.parameters + for batch in range(batch_size) + ] + coefficients = list(chain(*zip(*repeat(self.coefficients, batch_size)))) + torch_indices = torch.tensor(param_indices_interpoint) return ( - torch.tensor(param_indices), + torch_indices, torch.tensor( - [self._multiplier * c for c in self.coefficients], dtype=DTypeFloatTorch + [self._multiplier * c for c in coefficients], dtype=DTypeFloatTorch ), np.asarray(self._multiplier * self.rhs, dtype=DTypeFloatNumpy).item(), ) -@define(frozen=True) -class ContinuousLinearInterPointConstraint(ContinuousLinearConstraint): - """Class for inter-point constraints. - - An inter-point constraint is a constraint that is defined over full batches. That - is, and inter-point constraint of the form ``param_1 + 2*param_2 <=2`` means that - the sum of ``param2`` plus two times the sum of ``param_2`` across the full batch - must not exceed 2. - """ - - eval_during_creation = False - eval_during_modeling = True - numerical_only = True - - @override - def to_botorch( - self, - parameters: Sequence[NumericalContinuousParameter], - idx_offset: int = 0, - batch_size: int = 1, - ) -> tuple[Tensor, Tensor, float]: - """Cast the constraint in a format required by botorch. - - Used in calling ``optimize_acqf_*`` functions, for details see - https://botorch.org/api/optim.html#botorch.optim.optimize.optimize_acqf - - Args: - parameters: The parameter objects of the continuous space. - idx_offset: Offset to the provided parameter indices. - batch_size: The size of the batch for which the constraint is applied. - - Raises: - ValueError: If ``batch_size`` is smaller than 1. - - Returns: - The tuple required by botorch. - """ - if batch_size < 1: - raise ValueError(f"Batch size must be at least 1 but is {batch_size}.") - - import torch - - from baybe.utils.torch import DTypeFloatTorch - - param_names = [p.name for p in parameters] - # Get the indices of the parameters used in the constraint - param_index = {name: param_names.index(name) for name in self.parameters} - param_indices = [ - (batch, param_index[name] + idx_offset) - for name in self.parameters - for batch in range(batch_size) - ] - coefficients = list(chain(*zip(*repeat(self.coefficients, batch_size)))) - return ( - torch.tensor(param_indices), - torch.tensor(coefficients, dtype=DTypeFloatTorch), - np.asarray(self.rhs, dtype=DTypeFloatNumpy).item(), - ) - - @define class ContinuousCardinalityConstraint( CardinalityConstraint, ContinuousNonlinearConstraint diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py index 96cded5e3..221457049 100644 --- a/baybe/recommenders/pure/bayesian/botorch.py +++ b/baybe/recommenders/pure/bayesian/botorch.py @@ -11,9 +11,6 @@ from typing_extensions import override from baybe.acquisition.acqfs import qThompsonSampling -from baybe.constraints import ( - ContinuousLinearInterPointConstraint, -) from baybe.exceptions import ( IncompatibilityError, IncompatibleAcquisitionFunctionError, @@ -181,25 +178,6 @@ def _recommend_continuous( import torch from botorch.optim import optimize_acqf - interpoint_constraints_lin_eq = ( - [ - c.to_botorch(subspace_continuous.parameters, batch_size=batch_size) - for c in subspace_continuous.constraints_ip_lin_eq - if isinstance(c, ContinuousLinearInterPointConstraint) and c.is_eq - ] - if subspace_continuous.has_interpoint_constraints - else [] - ) - interpoint_constraints_lin_ineq = ( - [ - c.to_botorch(subspace_continuous.parameters, batch_size=batch_size) - for c in subspace_continuous.constraints_ip_lin_ineq - if isinstance(c, ContinuousLinearInterPointConstraint) and not c.is_eq - ] - if subspace_continuous.has_interpoint_constraints - else [] - ) - points, _ = optimize_acqf( acq_function=self._botorch_acqf, bounds=torch.from_numpy(subspace_continuous.comp_rep_bounds.values), @@ -207,16 +185,14 @@ def _recommend_continuous( num_restarts=self.n_restarts, raw_samples=self.n_raw_samples, equality_constraints=[ - c.to_botorch(subspace_continuous.parameters) + c.to_botorch(subspace_continuous.parameters, batch_size=batch_size) for c in subspace_continuous.constraints_lin_eq ] - + interpoint_constraints_lin_eq or None, # TODO: https://github.com/pytorch/botorch/issues/2042 inequality_constraints=[ - c.to_botorch(subspace_continuous.parameters) + c.to_botorch(subspace_continuous.parameters, batch_size=batch_size) for c in subspace_continuous.constraints_lin_ineq ] - + interpoint_constraints_lin_ineq or None, # TODO: https://github.com/pytorch/botorch/issues/2042 sequential=self.sequential_continuous, ) diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index 47d8dfa9e..45a279fac 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -16,7 +16,6 @@ from baybe.constraints import ( ContinuousCardinalityConstraint, ContinuousLinearConstraint, - ContinuousLinearInterPointConstraint, ) from baybe.constraints.base import ContinuousConstraint, ContinuousNonlinearConstraint from baybe.constraints.validation import ( @@ -70,16 +69,6 @@ class SubspaceContinuous(SerialMixin): ) """Nonlinear constraints.""" - constraints_ip_lin_eq: tuple[ContinuousLinearInterPointConstraint, ...] = field( - converter=to_tuple, factory=tuple - ) - "Linear interpoint equality constraints." - - constraints_ip_lin_ineq: tuple[ContinuousLinearInterPointConstraint, ...] = field( - converter=to_tuple, factory=tuple - ) - "Linear interpoint inequality constraints." - @override def __str__(self) -> str: if self.is_empty: @@ -97,18 +86,10 @@ def __str__(self) -> str: nonlin_constraints_list = [ constr.summary() for constr in self.constraints_nonlin ] - ip_eq_constraints_list = [ - constr.summary() for constr in self.constraints_ip_lin_eq - ] - ip_ineq_constraints_list = [ - constr.summary() for constr in self.constraints_ip_lin_ineq - ] param_df = pd.DataFrame(param_list) lin_eq_df = pd.DataFrame(eq_constraints_list) lin_ineq_df = pd.DataFrame(ineq_constraints_list) - ip_lin_eq_df = pd.DataFrame(ip_eq_constraints_list) - ip_lin_ineq_df = pd.DataFrame(ip_ineq_constraints_list) nonlinear_df = pd.DataFrame(nonlin_constraints_list) fields = [ @@ -117,14 +98,6 @@ def __str__(self) -> str: ), to_string("Linear Equality Constraints", pretty_print_df(lin_eq_df)), to_string("Linear Inequality Constraints", pretty_print_df(lin_ineq_df)), - to_string( - "Linear Interpoint Equality Constraints", - pretty_print_df(ip_lin_eq_df), - ), - to_string( - "Linear Interpoint Inequality Constraints", - pretty_print_df(ip_lin_ineq_df), - ), to_string("Non-linear Constraints", pretty_print_df(nonlinear_df)), ] @@ -165,32 +138,6 @@ def _validate_constraints_lin_ineq( f"the 'operator' for all list items should be '>=' or '<='." ) - @constraints_ip_lin_eq.validator - def _validate_constraints_ip_lin_eq( - self, _, lst: list[ContinuousLinearInterPointConstraint] - ) -> None: - """Validate linear interpoint equality constraints.""" - # TODO Remove once eq and ineq constraints are consolidated into one list - if not all(c.is_eq for c in lst): - raise ValueError( - f"The list '{fields(self.__class__).constraints_ip_lin_eq.name}' of " - f"{self.__class__.__name__} only accepts equality constraints, i.e. " - f"the 'operator' for all list items should be '='." - ) - - @constraints_ip_lin_ineq.validator - def _validate_constraints_ip_lin_ineq( - self, _, lst: list[ContinuousLinearConstraint] - ) -> None: - """Validate linear interpoint inequality constraints.""" - # TODO Remove once eq and ineq constraints are consolidated into one list - if any(c.is_eq for c in lst): - raise ValueError( - f"The list '{fields(self.__class__).constraints_ip_lin_ineq.name}' of " - f"{self.__class__.__name__} only accepts inequality constraints, i.e. " - f"the 'operator' for all list items should be '>=' or '<='." - ) - @constraints_nonlin.validator def _validate_constraints_nonlin(self, _, __) -> None: """Validate nonlinear constraints.""" @@ -235,30 +182,12 @@ def from_product( constraints_lin_eq=[ # type:ignore[attr-misc] c for c in constraints - if ( - isinstance(c, ContinuousLinearConstraint) - and not isinstance(c, ContinuousLinearInterPointConstraint) - and c.is_eq - ) + if (isinstance(c, ContinuousLinearConstraint) and c.is_eq) ], constraints_lin_ineq=[ # type:ignore[attr-misc] c for c in constraints - if ( - isinstance(c, ContinuousLinearConstraint) - and not isinstance(c, ContinuousLinearInterPointConstraint) - and not c.is_eq - ) - ], - constraints_ip_lin_eq=[ # type:ignore[misc] - c - for c in constraints - if (isinstance(c, ContinuousLinearInterPointConstraint) and c.is_eq) - ], - constraints_ip_lin_ineq=[ # type:ignore[misc] - c - for c in constraints - if (isinstance(c, ContinuousLinearInterPointConstraint) and not c.is_eq) + if (isinstance(c, ContinuousLinearConstraint) and not c.is_eq) ], constraints_nonlin=[ # type:ignore[attr-misc] c for c in constraints if isinstance(c, ContinuousNonlinearConstraint) @@ -366,8 +295,6 @@ def is_constrained(self) -> bool: """Return whether the subspace is constrained in any way.""" return any( ( - self.constraints_ip_lin_eq, - self.constraints_ip_lin_ineq, self.constraints_lin_eq, self.constraints_lin_ineq, self.constraints_nonlin, @@ -377,7 +304,9 @@ def is_constrained(self) -> bool: @property def has_interpoint_constraints(self) -> bool: """Return whether or not the space has any interpoint constraints.""" - return any((self.constraints_ip_lin_eq, self.constraints_ip_lin_ineq)) + return any( + c.is_interpoint for c in self.constraints_lin_eq + self.constraints_lin_ineq + ) def _drop_parameters(self, parameter_names: Collection[str]) -> SubspaceContinuous: """Create a copy of the subspace with certain parameters removed. @@ -532,19 +461,21 @@ def _sample_from_polytope_with_interpoint_constraints( # We start with the general constraints before going to interpoint constraints for c in [*self.constraints_lin_eq, *self.constraints_lin_ineq]: - param_indices, coefficients, rhs = c.to_botorch(self.parameters) - for b in range(batch_size): - botorch_tuple = (param_indices + b * num_of_params, coefficients, rhs) - if c.is_eq: - eq_constraints.append(botorch_tuple) - else: - ineq_constraints.append(botorch_tuple) - - if self.has_interpoint_constraints: - for c in [ - *self.constraints_ip_lin_eq, - *self.constraints_ip_lin_ineq, - ]: + if not c.is_interpoint: + param_indices, coefficients, rhs = c.to_botorch( + self.parameters, batch_size=batch_size + ) + for b in range(batch_size): + botorch_tuple = ( + param_indices + b * num_of_params, + coefficients, + rhs, + ) + if c.is_eq: + eq_constraints.append(botorch_tuple) + else: + ineq_constraints.append(botorch_tuple) + else: # Get the indices of the parameters used in the constraint param_index = { name: self.parameter_names.index(name) for name in c.parameters diff --git a/examples/Constraints_Continuous/linear_constraints.py b/examples/Constraints_Continuous/linear_constraints.py index 2e42f0729..a93428ab1 100644 --- a/examples/Constraints_Continuous/linear_constraints.py +++ b/examples/Constraints_Continuous/linear_constraints.py @@ -18,10 +18,7 @@ from botorch.test_functions import Rastrigin from baybe import Campaign -from baybe.constraints import ( - ContinuousLinearConstraint, - ContinuousLinearInterPointConstraint, -) +from baybe.constraints import ContinuousLinearConstraint from baybe.parameters import NumericalContinuousParameter from baybe.searchspace import SearchSpace from baybe.targets import NumericalTarget @@ -158,20 +155,18 @@ inter_constraints = [ - ContinuousLinearInterPointConstraint( - parameters=["x_1"], - operator=">=", - coefficients=[1], - rhs=2.5, + ContinuousLinearConstraint( + parameters=["x_1"], operator=">=", coefficients=[1], rhs=2.5, is_interpoint=True ), - ContinuousLinearInterPointConstraint( - parameters=["x_2"], operator="=", coefficients=[1], rhs=5 + ContinuousLinearConstraint( + parameters=["x_2"], operator="=", coefficients=[1], rhs=5, is_interpoint=True ), - ContinuousLinearInterPointConstraint( + ContinuousLinearConstraint( parameters=["x_3", "x_4"], operator=">=", coefficients=[2, -1], rhs=5, + is_interpoint=True, ), ] diff --git a/tests/conftest.py b/tests/conftest.py index d7e04852f..c682ce617 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,7 +29,6 @@ from baybe.constraints import ( ContinuousCardinalityConstraint, ContinuousLinearConstraint, - ContinuousLinearInterPointConstraint, DiscreteCardinalityConstraint, DiscreteCustomConstraint, DiscreteDependenciesConstraint, @@ -554,29 +553,33 @@ def custom_function(df: pd.DataFrame) -> pd.Series: coefficients=[1.0, 3.0], rhs=0.3, ), - "InterConstraint_1": ContinuousLinearInterPointConstraint( + "InterConstraint_1": ContinuousLinearConstraint( parameters=["Conti_finite1"], operator="=", coefficients=[1], rhs=0.3, + is_interpoint=True, ), - "InterConstraint_2": ContinuousLinearInterPointConstraint( + "InterConstraint_2": ContinuousLinearConstraint( parameters=["Conti_finite1"], operator=">=", coefficients=[2], rhs=0.3, + is_interpoint=True, ), - "InterConstraint_3": ContinuousLinearInterPointConstraint( + "InterConstraint_3": ContinuousLinearConstraint( parameters=["Conti_finite1", "Conti_finite2"], operator="=", coefficients=[1, 1], rhs=0.3, + is_interpoint=True, ), - "InterConstraint_4": ContinuousLinearInterPointConstraint( + "InterConstraint_4": ContinuousLinearConstraint( parameters=["Conti_finite1", "Conti_finite2"], coefficients=[2, -1], operator=">=", rhs=0.3, + is_interpoint=True, ), } return [ From dec81979b80727aa62ebd54e15a5392cb40acd98 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Mon, 11 Nov 2024 18:01:28 +0100 Subject: [PATCH 08/47] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a04d1fb5..979fd7fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `arrays_to_dataframes` decorator to create lookups from array-based callables - `DiscreteConstraint.get_valid` to conveniently access valid candidates - Functionality for persisting benchmarking results on S3 from a manual pipeline run +- Continuous inter-point constraints via new `is_interpoint` attribute ### Changed - `SubstanceParameter` encodings are now computed exclusively with the From cf58894b49fdfc1f887f3c314fd2f4c4e807cd36 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Wed, 20 Nov 2024 12:19:12 +0100 Subject: [PATCH 09/47] Raise error when using interpoint constraints in hybrid spaces --- baybe/recommenders/pure/bayesian/botorch.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py index 221457049..8b6bafa66 100644 --- a/baybe/recommenders/pure/bayesian/botorch.py +++ b/baybe/recommenders/pure/bayesian/botorch.py @@ -234,7 +234,10 @@ def _recommend_hybrid( Returns: The recommended points. """ - # TODO Interpoint constraints are not yet enabled in hybrid search spaces + if searchspace.continuous.has_interpoint_constraints: + raise NotImplementedError( + "Interpoint constraints are not available in hybrid spaces." + ) # For batch size > 1, this optimizer needs a MC acquisition function if batch_size > 1 and not self.acquisition_function.is_mc: From 86e476c6e98f29c263d540fa20717abbb6529c8c Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Wed, 20 Nov 2024 12:20:10 +0100 Subject: [PATCH 10/47] Replace inter-point by interpoint --- baybe/constraints/continuous.py | 4 ++-- baybe/searchspace/continuous.py | 2 +- examples/Constraints_Continuous/linear_constraints.py | 8 ++++---- tests/constraints/test_constraints_continuous.py | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py index 8a1939a03..943084a98 100644 --- a/baybe/constraints/continuous.py +++ b/baybe/constraints/continuous.py @@ -49,8 +49,8 @@ class ContinuousLinearConstraint(ContinuousConstraint): is_interpoint: bool = field(default=False, validator=instance_of(bool)) """Flag for defining an interpoint constraint. - An inter-point constraint is a constraint that is defined over full batches. That - is, and inter-point constraint of the form ``param_1 + 2*param_2 <=2`` means that + An interpoint constraint is a constraint that is defined over full batches. That + is, and interpoint constraint of the form ``param_1 + 2*param_2 <=2`` means that the sum of ``param2`` plus two times the sum of ``param_2`` across the full batch must not exceed 2. """ diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index 45a279fac..16d8fa25b 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -445,7 +445,7 @@ def _sample_from_polytope_with_interpoint_constraints( """Draw uniform random samples from a polytope with interpoint constraints.""" # If the space has interpoint constraints, we need to sample from a larger # searchspace that models the batch size via additional dimension. This is - # necessary since `get_polytope_samples` cannot handle inter-point constraints, + # necessary since `get_polytope_samples` cannot handle interpoint constraints, # see https://github.com/pytorch/botorch/issues/2468 import torch diff --git a/examples/Constraints_Continuous/linear_constraints.py b/examples/Constraints_Continuous/linear_constraints.py index a93428ab1..ea37cb9d3 100644 --- a/examples/Constraints_Continuous/linear_constraints.py +++ b/examples/Constraints_Continuous/linear_constraints.py @@ -141,13 +141,13 @@ ) -### Using inter-point constraints +### Using interpoint constraints -# It is also possible to require inter-point constraints which constraint the value of +# It is also possible to require interpoint constraints which constraint the value of # a single parameter across a full batch. # Since these constraints require information about the batch size, they are not used # during the creation of the search space but handed over to the `recommend` call. -# This example models the following inter-point constraints and combines them also +# This example models the following interpoint constraints and combines them also # with regular constraints. # 1. The sum of `x_1` across all batches needs to be >= 2.5. # 2. The sum of `x_2` across all batches needs to be exactly 5. @@ -191,7 +191,7 @@ rec["Target"] = target_values inter_campaign.add_measurements(rec) - # Check inter-point constraints + # Check interpoint constraints assert rec["x_1"].sum() >= 2.5 - TOLERANCE assert np.isclose(rec["x_2"].sum(), 5) assert 2 * rec["x_3"].sum() - rec["x_4"].sum() >= 2.5 - TOLERANCE diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py index 96e04eac3..e3d14d54d 100644 --- a/tests/constraints/test_constraints_continuous.py +++ b/tests/constraints/test_constraints_continuous.py @@ -72,7 +72,7 @@ def test_inequality3(campaign, n_iterations, batch_size): @pytest.mark.parametrize("constraint_names", [["InterConstraint_1"]]) @pytest.mark.parametrize("batch_size", [5], ids=["b5"]) def test_interpoint_equality_single_parameter(campaign, n_iterations, batch_size): - """Test single parameter inter-point equality constraint.""" + """Test single parameter interpoint equality constraint.""" run_iterations(campaign, n_iterations, batch_size, add_noise=False) res = campaign.measurements print(res) @@ -86,7 +86,7 @@ def test_interpoint_equality_single_parameter(campaign, n_iterations, batch_size @pytest.mark.parametrize("constraint_names", [["InterConstraint_2"]]) @pytest.mark.parametrize("batch_size", [5], ids=["b5"]) def test_interpoint_inequality_single_parameter(campaign, n_iterations, batch_size): - """Test single parameter inter-point inequality constraint.""" + """Test single parameter interpoint inequality constraint.""" run_iterations(campaign, n_iterations, batch_size, add_noise=False) res = campaign.measurements print(res) @@ -100,7 +100,7 @@ def test_interpoint_inequality_single_parameter(campaign, n_iterations, batch_si @pytest.mark.parametrize("constraint_names", [["InterConstraint_3"]]) @pytest.mark.parametrize("batch_size", [5], ids=["b5"]) def test_interpoint_equality_multiple_parameters(campaign, n_iterations, batch_size): - """Test inter-point equality constraint involving multiple parameters.""" + """Test interpoint equality constraint involving multiple parameters.""" run_iterations(campaign, n_iterations, batch_size, add_noise=False) res = campaign.measurements print(res) @@ -116,7 +116,7 @@ def test_interpoint_equality_multiple_parameters(campaign, n_iterations, batch_s @pytest.mark.parametrize("constraint_names", [["InterConstraint_4"]]) @pytest.mark.parametrize("batch_size", [5], ids=["b5"]) def test_interpoint_inequality_multiple_parameters(campaign, n_iterations, batch_size): - """Test inter-point inequality constraint involving multiple parameters.""" + """Test interpoint inequality constraint involving multiple parameters.""" run_iterations(campaign, n_iterations, batch_size, add_noise=False) res = campaign.measurements print(res) From 4fa77a33aff52b02434cc517e8d0d5af546f0222 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Wed, 20 Nov 2024 12:22:39 +0100 Subject: [PATCH 11/47] Remove duplication of nonlin_constraint_list --- baybe/searchspace/continuous.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index 16d8fa25b..84bfbff09 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -83,9 +83,6 @@ def __str__(self) -> str: nonlin_constraints_list = [ constr.summary() for constr in self.constraints_nonlin ] - nonlin_constraints_list = [ - constr.summary() for constr in self.constraints_nonlin - ] param_df = pd.DataFrame(param_list) lin_eq_df = pd.DataFrame(eq_constraints_list) From d8a99736bdafafa8fb294e1daa09be0b6f16bac2 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Wed, 20 Nov 2024 12:26:49 +0100 Subject: [PATCH 12/47] Rephrase docstrings of properties --- baybe/searchspace/continuous.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index 84bfbff09..7aa3d57ca 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -289,7 +289,7 @@ def comp_rep_bounds(self) -> pd.DataFrame: @property def is_constrained(self) -> bool: - """Return whether the subspace is constrained in any way.""" + """Boolean flag indicating whether the subspace is constrained in any way.""" return any( ( self.constraints_lin_eq, @@ -300,7 +300,7 @@ def is_constrained(self) -> bool: @property def has_interpoint_constraints(self) -> bool: - """Return whether or not the space has any interpoint constraints.""" + """Boolean flag indicating whether the space has any interpoint constraints.""" return any( c.is_interpoint for c in self.constraints_lin_eq + self.constraints_lin_ineq ) From b7ae3e6ceca951d0f7937609535b95466e2797e6 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Wed, 20 Nov 2024 12:33:45 +0100 Subject: [PATCH 13/47] Add alias for is_interpoint field --- baybe/constraints/continuous.py | 4 +++- examples/Constraints_Continuous/linear_constraints.py | 6 +++--- tests/conftest.py | 8 ++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py index 943084a98..4b03783aa 100644 --- a/baybe/constraints/continuous.py +++ b/baybe/constraints/continuous.py @@ -46,7 +46,9 @@ class ContinuousLinearConstraint(ContinuousConstraint): rhs: float = field(default=0.0, converter=float, validator=finite_float) """Right-hand side value of the in-/equality.""" - is_interpoint: bool = field(default=False, validator=instance_of(bool)) + is_interpoint: bool = field( + alias="interpoint", default=False, validator=instance_of(bool) + ) """Flag for defining an interpoint constraint. An interpoint constraint is a constraint that is defined over full batches. That diff --git a/examples/Constraints_Continuous/linear_constraints.py b/examples/Constraints_Continuous/linear_constraints.py index ea37cb9d3..ec7548fc7 100644 --- a/examples/Constraints_Continuous/linear_constraints.py +++ b/examples/Constraints_Continuous/linear_constraints.py @@ -156,17 +156,17 @@ inter_constraints = [ ContinuousLinearConstraint( - parameters=["x_1"], operator=">=", coefficients=[1], rhs=2.5, is_interpoint=True + parameters=["x_1"], operator=">=", coefficients=[1], rhs=2.5, interpoint=True ), ContinuousLinearConstraint( - parameters=["x_2"], operator="=", coefficients=[1], rhs=5, is_interpoint=True + parameters=["x_2"], operator="=", coefficients=[1], rhs=5, interpoint=True ), ContinuousLinearConstraint( parameters=["x_3", "x_4"], operator=">=", coefficients=[2, -1], rhs=5, - is_interpoint=True, + interpoint=True, ), ] diff --git a/tests/conftest.py b/tests/conftest.py index c682ce617..ef0c242ec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -558,28 +558,28 @@ def custom_function(df: pd.DataFrame) -> pd.Series: operator="=", coefficients=[1], rhs=0.3, - is_interpoint=True, + interpoint=True, ), "InterConstraint_2": ContinuousLinearConstraint( parameters=["Conti_finite1"], operator=">=", coefficients=[2], rhs=0.3, - is_interpoint=True, + interpoint=True, ), "InterConstraint_3": ContinuousLinearConstraint( parameters=["Conti_finite1", "Conti_finite2"], operator="=", coefficients=[1, 1], rhs=0.3, - is_interpoint=True, + interpoint=True, ), "InterConstraint_4": ContinuousLinearConstraint( parameters=["Conti_finite1", "Conti_finite2"], coefficients=[2, -1], operator=">=", rhs=0.3, - is_interpoint=True, + interpoint=True, ), } return [ From 7edbb5b9da077552f622b21424bf33a8c2b22a77 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Wed, 20 Nov 2024 12:35:30 +0100 Subject: [PATCH 14/47] Improve description of is_interpoint field --- baybe/constraints/continuous.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py index 4b03783aa..ec5d03aef 100644 --- a/baybe/constraints/continuous.py +++ b/baybe/constraints/continuous.py @@ -51,10 +51,10 @@ class ContinuousLinearConstraint(ContinuousConstraint): ) """Flag for defining an interpoint constraint. - An interpoint constraint is a constraint that is defined over full batches. That - is, and interpoint constraint of the form ``param_1 + 2*param_2 <=2`` means that - the sum of ``param2`` plus two times the sum of ``param_2`` across the full batch - must not exceed 2. + While intra-point constraints impose conditions on each individual point of a batch, + interpoint constraints do so **across** the points of the batch. That is, an + interpoint constraint of the form ``x_1 + x_2 <= 1`` enforces that the sum of all + ``x_1`` values plus the sum of all ``x_2`` values in the batch must not exceed 1. """ @coefficients.validator From 64eff39d59020bf6fb7298ab559f4c0ee2bb9a8e Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Wed, 20 Nov 2024 12:44:51 +0100 Subject: [PATCH 15/47] Remove default for batch_size in to_botorch --- baybe/constraints/continuous.py | 2 +- baybe/searchspace/continuous.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py index ec5d03aef..1367229e4 100644 --- a/baybe/constraints/continuous.py +++ b/baybe/constraints/continuous.py @@ -113,7 +113,7 @@ def to_botorch( self, parameters: Sequence[NumericalContinuousParameter], idx_offset: int = 0, - batch_size: int = 1, + batch_size: int | None = None, ) -> tuple[Tensor, Tensor, float]: """Cast the constraint in a format required by botorch. diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index 7aa3d57ca..ad97e3be6 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -459,9 +459,7 @@ def _sample_from_polytope_with_interpoint_constraints( # We start with the general constraints before going to interpoint constraints for c in [*self.constraints_lin_eq, *self.constraints_lin_ineq]: if not c.is_interpoint: - param_indices, coefficients, rhs = c.to_botorch( - self.parameters, batch_size=batch_size - ) + param_indices, coefficients, rhs = c.to_botorch(self.parameters) for b in range(batch_size): botorch_tuple = ( param_indices + b * num_of_params, From c367115956d2a4e7d8cad6ead2398b753302b502 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Wed, 20 Nov 2024 12:50:45 +0100 Subject: [PATCH 16/47] Remove batch_size parametrization for interpoint tests --- tests/constraints/test_constraints_continuous.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py index e3d14d54d..a4fc54a83 100644 --- a/tests/constraints/test_constraints_continuous.py +++ b/tests/constraints/test_constraints_continuous.py @@ -70,7 +70,6 @@ def test_inequality3(campaign, n_iterations, batch_size): @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) @pytest.mark.parametrize("constraint_names", [["InterConstraint_1"]]) -@pytest.mark.parametrize("batch_size", [5], ids=["b5"]) def test_interpoint_equality_single_parameter(campaign, n_iterations, batch_size): """Test single parameter interpoint equality constraint.""" run_iterations(campaign, n_iterations, batch_size, add_noise=False) @@ -84,7 +83,6 @@ def test_interpoint_equality_single_parameter(campaign, n_iterations, batch_size @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) @pytest.mark.parametrize("constraint_names", [["InterConstraint_2"]]) -@pytest.mark.parametrize("batch_size", [5], ids=["b5"]) def test_interpoint_inequality_single_parameter(campaign, n_iterations, batch_size): """Test single parameter interpoint inequality constraint.""" run_iterations(campaign, n_iterations, batch_size, add_noise=False) @@ -98,7 +96,6 @@ def test_interpoint_inequality_single_parameter(campaign, n_iterations, batch_si @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) @pytest.mark.parametrize("constraint_names", [["InterConstraint_3"]]) -@pytest.mark.parametrize("batch_size", [5], ids=["b5"]) def test_interpoint_equality_multiple_parameters(campaign, n_iterations, batch_size): """Test interpoint equality constraint involving multiple parameters.""" run_iterations(campaign, n_iterations, batch_size, add_noise=False) @@ -114,7 +111,6 @@ def test_interpoint_equality_multiple_parameters(campaign, n_iterations, batch_s @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) @pytest.mark.parametrize("constraint_names", [["InterConstraint_4"]]) -@pytest.mark.parametrize("batch_size", [5], ids=["b5"]) def test_interpoint_inequality_multiple_parameters(campaign, n_iterations, batch_size): """Test interpoint inequality constraint involving multiple parameters.""" run_iterations(campaign, n_iterations, batch_size, add_noise=False) @@ -132,7 +128,6 @@ def test_interpoint_inequality_multiple_parameters(campaign, n_iterations, batch @pytest.mark.parametrize( "constraint_names", [["ContiConstraint_4", "InterConstraint_2"]] ) -@pytest.mark.parametrize("batch_size", [5], ids=["b5"]) def test_interpoint_normal_mix(campaign, n_iterations, batch_size): """Test mixing interpoint and normal inequality constraints.""" run_iterations(campaign, n_iterations, batch_size, add_noise=False) From a40394848b0f20967149882ddd732b200087bfde Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Wed, 20 Nov 2024 14:17:40 +0100 Subject: [PATCH 17/47] Create separate interpoint example --- examples/Constraints_Continuous/interpoint.py | 119 ++++++++++++++++++ .../linear_constraints.py | 56 --------- 2 files changed, 119 insertions(+), 56 deletions(-) create mode 100644 examples/Constraints_Continuous/interpoint.py diff --git a/examples/Constraints_Continuous/interpoint.py b/examples/Constraints_Continuous/interpoint.py new file mode 100644 index 000000000..3bc5ee8f1 --- /dev/null +++ b/examples/Constraints_Continuous/interpoint.py @@ -0,0 +1,119 @@ +## Example for linear interpoint constraints in a continuous searchspace + +# Example for optimizing a synthetic test functions in a continuous space with linear +# interpoint constraints. +# While intrapoint constraints impose conditions on each individual point of a batch, +# interpoint constraints do so **across** the points of the batch. That is, an +# interpoint constraint of the form ``x_1 + x_2 <= 1`` enforces that the sum of all +# ``x_1`` values plus the sum of all ``x_2`` values in the batch must not exceed 1. + +# This example is a variant of the example for linear constraints, and we thus refer +# to [`linear_constraints`](./linear_constraints.md) for more details and explanations. + +### Necessary imports for this example + +import os + +import numpy as np +from botorch.test_functions import Rastrigin + +from baybe import Campaign +from baybe.constraints import ContinuousLinearConstraint +from baybe.objectives import SingleTargetObjective +from baybe.parameters import NumericalContinuousParameter +from baybe.searchspace import SearchSpace +from baybe.targets import NumericalTarget +from baybe.utils.botorch_wrapper import botorch_function_wrapper + +### Defining the test function + +DIMENSION = 4 +TestFunctionClass = Rastrigin + +if not hasattr(TestFunctionClass, "dim"): + TestFunction = TestFunctionClass(dim=DIMENSION) +else: + TestFunction = TestFunctionClass() + DIMENSION = TestFunctionClass().dim + +BOUNDS = TestFunction.bounds +WRAPPED_FUNCTION = botorch_function_wrapper(test_function=TestFunction) + +### Creating the searchspace and the objective + +parameters = [ + NumericalContinuousParameter( + name=f"x_{k+1}", + bounds=(BOUNDS[0, k], BOUNDS[1, k]), + ) + for k in range(DIMENSION) +] + +### Defining interpoint constraints + +# This example models the following interpoint constraints: +# 1. The sum of `x_1` across all batches needs to be >= 2.5. +# 2. The sum of `x_2` across all batches needs to be exactly 5. +# 3. The sum of `2*x_3` minus the sum of `x_4` across all batches needs to be >= 5. + + +inter_constraints = [ + ContinuousLinearConstraint( + parameters=["x_1"], operator=">=", coefficients=[1], rhs=2.5, interpoint=True + ), + ContinuousLinearConstraint( + parameters=["x_2"], operator="=", coefficients=[1], rhs=5, interpoint=True + ), + ContinuousLinearConstraint( + parameters=["x_3", "x_4"], + operator=">=", + coefficients=[2, -1], + rhs=5, + interpoint=True, + ), +] + +### Construct search space without the previous constraints + +searchspace = SearchSpace.from_product( + parameters=parameters, constraints=inter_constraints +) +objective = SingleTargetObjective(target=NumericalTarget(name="Target", mode="MIN")) + +campaign = Campaign( + searchspace=searchspace, + objective=objective, +) + +# Improve running time for CI via SMOKE_TEST + +SMOKE_TEST = "SMOKE_TEST" in os.environ + +BATCH_SIZE = 4 if SMOKE_TEST else 5 +N_ITERATIONS = 2 if SMOKE_TEST else 3 +TOLERANCE = 0.01 + +for k in range(N_ITERATIONS): + rec = campaign.recommend(batch_size=BATCH_SIZE) + + target_values = [] + for index, row in rec.iterrows(): + target_values.append(WRAPPED_FUNCTION(*row.to_list())) + + rec["Target"] = target_values + campaign.add_measurements(rec) + + # Check interpoint constraints + + print( + "The sum of `x_1` across all batches is at least >= 2.5", + rec["x_1"].sum() >= 2.5 - TOLERANCE, + ) + print( + "The sum of `x_2` across all batches is exactly 5", + np.isclose(rec["x_2"].sum(), 5), + ) + print( + "The sum of `2*x_3` minus the sum of `x_4` across all batches is at least >= 5", + 2 * rec["x_3"].sum() - rec["x_4"].sum() >= 2.5 - TOLERANCE, + ) diff --git a/examples/Constraints_Continuous/linear_constraints.py b/examples/Constraints_Continuous/linear_constraints.py index ec7548fc7..7eba01d56 100644 --- a/examples/Constraints_Continuous/linear_constraints.py +++ b/examples/Constraints_Continuous/linear_constraints.py @@ -139,59 +139,3 @@ "2.0*x_2 + 3.0*x_4 <= 1.0 satisfied in all recommendations? ", (2.0 * measurements["x_2"] + 3.0 * measurements["x_4"]).le(1.0 + TOLERANCE).all(), ) - - -### Using interpoint constraints - -# It is also possible to require interpoint constraints which constraint the value of -# a single parameter across a full batch. -# Since these constraints require information about the batch size, they are not used -# during the creation of the search space but handed over to the `recommend` call. -# This example models the following interpoint constraints and combines them also -# with regular constraints. -# 1. The sum of `x_1` across all batches needs to be >= 2.5. -# 2. The sum of `x_2` across all batches needs to be exactly 5. -# 3. The sum of `2*x_3` minus the sum of `x_4` across all batches needs to be >= 5. - - -inter_constraints = [ - ContinuousLinearConstraint( - parameters=["x_1"], operator=">=", coefficients=[1], rhs=2.5, interpoint=True - ), - ContinuousLinearConstraint( - parameters=["x_2"], operator="=", coefficients=[1], rhs=5, interpoint=True - ), - ContinuousLinearConstraint( - parameters=["x_3", "x_4"], - operator=">=", - coefficients=[2, -1], - rhs=5, - interpoint=True, - ), -] - -### Construct search space without the previous constraints - -inter_searchspace = SearchSpace.from_product( - parameters=parameters, constraints=inter_constraints -) - -inter_campaign = Campaign( - searchspace=inter_searchspace, - objective=objective, -) - -for k in range(N_ITERATIONS): - rec = inter_campaign.recommend(batch_size=BATCH_SIZE) - - # target value are looked up via the botorch wrapper - target_values = [] - for index, row in rec.iterrows(): - target_values.append(WRAPPED_FUNCTION(*row.to_list())) - - rec["Target"] = target_values - inter_campaign.add_measurements(rec) - # Check interpoint constraints - assert rec["x_1"].sum() >= 2.5 - TOLERANCE - assert np.isclose(rec["x_2"].sum(), 5) - assert 2 * rec["x_3"].sum() - rec["x_4"].sum() >= 2.5 - TOLERANCE From 31afb21725f21ea59acb04313220aabcbe47aa23 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Wed, 20 Nov 2024 14:22:05 +0100 Subject: [PATCH 18/47] Add section about interpoint constraints to userguide --- docs/userguide/constraints.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/userguide/constraints.md b/docs/userguide/constraints.md index 61fff3e71..68195c88c 100644 --- a/docs/userguide/constraints.md +++ b/docs/userguide/constraints.md @@ -79,6 +79,28 @@ ContinuousLinearConstraint( A more detailed example can be found [here](../../examples/Constraints_Continuous/linear_constraints). +#### Interpoint constraints + +The constraints discussed so far all belong to the class of so called "intrapoint constraints". +That is, they impose conditions on each individual point of a batch. +In contrast to this, interpoint constraints do so **across** the points of the batch. +That is, an interpoint constraint of the form ``x_1 + x_2 <= 1`` enforces that the sum of all +``x_1`` values plus the sum of all ``x_2`` values in the batch must not exceed 1. + +They can be defined by using the `interpoint` keyword of the [`ContinuousLinearConstraint`](baybe.constraints.continuous.ContinuousLinearConstraint) +class as follows: +```python +from baybe.constraints import ContinuousLinearConstraint + +ContinuousLinearConstraint( + parameters=["x_1", "x_2"], + operator="<=", + coefficients=[1.0, 1.0], + rhs=1, + interpoint=True, +) +``` + ## Conditions Conditions are elements used within discrete constraints. While discrete constraints can operate on one or multiple parameters, a condition From eca061c8a035fbebf20badd335ffd0faccd7ec8b Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Wed, 20 Nov 2024 15:52:50 +0100 Subject: [PATCH 19/47] Add <= interpoint constraint test --- tests/conftest.py | 7 ++++++ .../test_constraints_continuous.py | 24 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index ef0c242ec..49b259255 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -581,6 +581,13 @@ def custom_function(df: pd.DataFrame) -> pd.Series: rhs=0.3, interpoint=True, ), + "InterConstraint_5": ContinuousLinearConstraint( + parameters=["Conti_finite1", "Conti_finite2"], + coefficients=[2, -1], + operator="<=", + rhs=0.3, + interpoint=True, + ), } return [ c_item diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py index a4fc54a83..45047e43c 100644 --- a/tests/constraints/test_constraints_continuous.py +++ b/tests/constraints/test_constraints_continuous.py @@ -111,8 +111,10 @@ def test_interpoint_equality_multiple_parameters(campaign, n_iterations, batch_s @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) @pytest.mark.parametrize("constraint_names", [["InterConstraint_4"]]) -def test_interpoint_inequality_multiple_parameters(campaign, n_iterations, batch_size): - """Test interpoint inequality constraint involving multiple parameters.""" +def test_geq_interpoint_inequality_multiple_parameters( + campaign, n_iterations, batch_size +): + """Test geq-interpoint inequality constraint involving multiple parameters.""" run_iterations(campaign, n_iterations, batch_size, add_noise=False) res = campaign.measurements print(res) @@ -124,6 +126,24 @@ def test_interpoint_inequality_multiple_parameters(campaign, n_iterations, batch ) +@pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) +@pytest.mark.parametrize("constraint_names", [["InterConstraint_5"]]) +def test_leq_interpoint_inequality_multiple_parameters( + campaign, n_iterations, batch_size +): + """Test leq-interpoint inequality constraint involving multiple parameters.""" + run_iterations(campaign, n_iterations, batch_size, add_noise=False) + res = campaign.measurements + print(res) + for batch in range(n_iterations): + res_batch = res[res["BatchNr"] == batch + 1] + print(campaign.searchspace.constraints) + assert ( + 2 * res_batch["Conti_finite1"].sum() - res_batch["Conti_finite2"].sum() + <= 0.301 + ) + + @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) @pytest.mark.parametrize( "constraint_names", [["ContiConstraint_4", "InterConstraint_2"]] From e08cd9e9d694a2143dfa1dc957a5b6067b6b3745 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Wed, 20 Nov 2024 16:00:10 +0100 Subject: [PATCH 20/47] Use coefficients and rhs from to_botorch call --- baybe/searchspace/continuous.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index ad97e3be6..63b0f429e 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -5,7 +5,7 @@ import gc import warnings from collections.abc import Collection, Sequence -from itertools import chain, repeat +from itertools import chain from typing import TYPE_CHECKING, Any, cast import numpy as np @@ -448,9 +448,6 @@ def _sample_from_polytope_with_interpoint_constraints( import torch from botorch.utils.sampling import get_polytope_samples - from baybe.utils.numerical import DTypeFloatNumpy - from baybe.utils.torch import DTypeFloatTorch - # The number of parameters is needed at some places for adjusting indices num_of_params = len(self.parameters) @@ -480,13 +477,13 @@ def _sample_from_polytope_with_interpoint_constraints( for param in c.parameters for batch in range(batch_size) ] - coefficients_list = list( - chain(*zip(*repeat(c.coefficients, batch_size))) + _, coefficients, rhs = c.to_botorch( + parameters=self.parameters, batch_size=batch_size ) botorch_tuple = ( torch.tensor(param_indices_list), - torch.tensor(coefficients_list, dtype=DTypeFloatTorch), - np.asarray(c.rhs, dtype=DTypeFloatNumpy).item(), + coefficients, + rhs, ) if c.is_eq: eq_constraints.append(botorch_tuple) From b8e08fbbee6e861b741381aa5c8203bb989746c9 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Wed, 20 Nov 2024 16:08:40 +0100 Subject: [PATCH 21/47] Unify _sample_from_polytope methods --- baybe/searchspace/continuous.py | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index 63b0f429e..fb4c32b51 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -414,11 +414,6 @@ def sample_uniform(self, batch_size: int = 1) -> pd.DataFrame: if not self.is_constrained: return self._sample_from_bounds(batch_size, self.comp_rep_bounds.values) - if self.has_interpoint_constraints: - return self._sample_from_polytope_with_interpoint_constraints( - batch_size, self.comp_rep_bounds.values - ) - # If there are neither cardinality nor interpoint constraints, we sample # directly from the polytope if len(self.constraints_cardinality) == 0: @@ -434,12 +429,12 @@ def _sample_from_bounds(self, batch_size: int, bounds: np.ndarray) -> pd.DataFra return pd.DataFrame(points, columns=self.parameter_names) - def _sample_from_polytope_with_interpoint_constraints( + def _sample_from_polytope( self, batch_size: int, bounds: np.ndarray, ) -> pd.DataFrame: - """Draw uniform random samples from a polytope with interpoint constraints.""" + """Draw uniform random samples from a polytope.""" # If the space has interpoint constraints, we need to sample from a larger # searchspace that models the batch size via additional dimension. This is # necessary since `get_polytope_samples` cannot handle interpoint constraints, @@ -502,25 +497,6 @@ def _sample_from_polytope_with_interpoint_constraints( points = points.reshape(batch_size, points.shape[-1] // batch_size) return pd.DataFrame(points, columns=self.parameter_names) - def _sample_from_polytope( - self, batch_size: int, bounds: np.ndarray - ) -> pd.DataFrame: - """Draw uniform random samples from a polytope.""" - import torch - from botorch.utils.sampling import get_polytope_samples - - points = get_polytope_samples( - n=batch_size, - bounds=torch.from_numpy(bounds), - equality_constraints=[ - c.to_botorch(self.parameters) for c in self.constraints_lin_eq - ], - inequality_constraints=[ - c.to_botorch(self.parameters) for c in self.constraints_lin_ineq - ], - ) - return pd.DataFrame(points, columns=self.parameter_names) - def _sample_from_polytope_with_cardinality_constraints( self, batch_size: int ) -> pd.DataFrame: From 3927a611c18e2604f185bfa95da8b641c612d2a8 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Wed, 20 Nov 2024 16:31:29 +0100 Subject: [PATCH 22/47] Add explicit assert for mypy --- baybe/constraints/continuous.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py index 1367229e4..bb0623ddb 100644 --- a/baybe/constraints/continuous.py +++ b/baybe/constraints/continuous.py @@ -143,6 +143,9 @@ def to_botorch( coefficients = self.coefficients torch_indices = torch.tensor(param_indices) else: + assert ( + batch_size is not None + ), "No batch_size set but using interpoint constraints" param_index = {name: param_names.index(name) for name in self.parameters} param_indices_interpoint = [ (batch, param_index[name] + idx_offset) From 57b0e980733fded82dcaeb01e1aa7263127d8f59 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Fri, 22 Nov 2024 14:33:40 +0100 Subject: [PATCH 23/47] Include motivation for interpoint constraints --- docs/userguide/constraints.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/userguide/constraints.md b/docs/userguide/constraints.md index 68195c88c..f7b161c17 100644 --- a/docs/userguide/constraints.md +++ b/docs/userguide/constraints.md @@ -79,13 +79,16 @@ ContinuousLinearConstraint( A more detailed example can be found [here](../../examples/Constraints_Continuous/linear_constraints). -#### Interpoint constraints +### Interpoint constraints The constraints discussed so far all belong to the class of so called "intrapoint constraints". That is, they impose conditions on each individual point of a batch. In contrast to this, interpoint constraints do so **across** the points of the batch. That is, an interpoint constraint of the form ``x_1 + x_2 <= 1`` enforces that the sum of all ``x_1`` values plus the sum of all ``x_2`` values in the batch must not exceed 1. +A possible relevant constraint might be that only 100ml of a given solvent are available for +a full batch, but there is no limit for the amount of solvent to use for a single experiment +within that batch. They can be defined by using the `interpoint` keyword of the [`ContinuousLinearConstraint`](baybe.constraints.continuous.ContinuousLinearConstraint) class as follows: From f0929321029c7fcaf9d50d13a7304361d83115f2 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Fri, 22 Nov 2024 14:36:00 +0100 Subject: [PATCH 24/47] Add comment on relevant constraint to example --- examples/Constraints_Continuous/interpoint.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/Constraints_Continuous/interpoint.py b/examples/Constraints_Continuous/interpoint.py index 3bc5ee8f1..3e0903c6d 100644 --- a/examples/Constraints_Continuous/interpoint.py +++ b/examples/Constraints_Continuous/interpoint.py @@ -6,6 +6,9 @@ # interpoint constraints do so **across** the points of the batch. That is, an # interpoint constraint of the form ``x_1 + x_2 <= 1`` enforces that the sum of all # ``x_1`` values plus the sum of all ``x_2`` values in the batch must not exceed 1. +# A possible relevant constraint might be that only 100ml of a given solvent are available for +# a full batch, but there is no limit for the amount of solvent to use for a single experiment +# within that batch. # This example is a variant of the example for linear constraints, and we thus refer # to [`linear_constraints`](./linear_constraints.md) for more details and explanations. From 881084d48e0e60dbb0bba407d99dd7ae76dafddf Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Fri, 22 Nov 2024 14:43:01 +0100 Subject: [PATCH 25/47] Add TOLERANCE to interpoint tests --- .../constraints/test_constraints_continuous.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py index 45047e43c..651502381 100644 --- a/tests/constraints/test_constraints_continuous.py +++ b/tests/constraints/test_constraints_continuous.py @@ -7,6 +7,8 @@ from baybe.constraints import ContinuousLinearConstraint from tests.conftest import run_iterations +TOLERANCE = 0.01 + @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) @pytest.mark.parametrize("constraint_names", [["ContiConstraint_1"]]) @@ -91,7 +93,7 @@ def test_interpoint_inequality_single_parameter(campaign, n_iterations, batch_si for batch in range(n_iterations): res_batch = res[res["BatchNr"] == batch + 1] - assert 2 * res_batch["Conti_finite1"].sum() >= 0.299 + assert 2 * res_batch["Conti_finite1"].sum() >= 0.3 - TOLERANCE @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) @@ -122,7 +124,7 @@ def test_geq_interpoint_inequality_multiple_parameters( res_batch = res[res["BatchNr"] == batch + 1] assert ( 2 * res_batch["Conti_finite1"].sum() - res_batch["Conti_finite2"].sum() - >= 0.299 + >= 0.3 - TOLERANCE ) @@ -140,7 +142,7 @@ def test_leq_interpoint_inequality_multiple_parameters( print(campaign.searchspace.constraints) assert ( 2 * res_batch["Conti_finite1"].sum() - res_batch["Conti_finite2"].sum() - <= 0.301 + <= 0.3 + TOLERANCE ) @@ -154,8 +156,14 @@ def test_interpoint_normal_mix(campaign, n_iterations, batch_size): res = campaign.measurements print(res) - assert res.at[0, "Conti_finite1"] + 3.0 * res.at[1, "Conti_finite1"] >= 0.299 - assert (1.0 * res["Conti_finite1"] + 3.0 * res["Conti_finite2"]).ge(0.299).all() + assert ( + res.at[0, "Conti_finite1"] + 3.0 * res.at[1, "Conti_finite1"] >= 0.3 - TOLERANCE + ) + assert ( + (1.0 * res["Conti_finite1"] + 3.0 * res["Conti_finite2"]) + .ge(0.3 - TOLERANCE) + .all() + ) @pytest.mark.slow From ad13835a6a55969ccbfe4ddc32ffe1a8a7c60557 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Fri, 22 Nov 2024 14:44:25 +0100 Subject: [PATCH 26/47] Fix test mixing normal and interpoint constraint --- tests/constraints/test_constraints_continuous.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py index 651502381..fe727c0bd 100644 --- a/tests/constraints/test_constraints_continuous.py +++ b/tests/constraints/test_constraints_continuous.py @@ -156,9 +156,10 @@ def test_interpoint_normal_mix(campaign, n_iterations, batch_size): res = campaign.measurements print(res) - assert ( - res.at[0, "Conti_finite1"] + 3.0 * res.at[1, "Conti_finite1"] >= 0.3 - TOLERANCE - ) + for batch in range(n_iterations): + res_batch = res[res["BatchNr"] == batch + 1] + assert 2 * res_batch["Conti_finite1"].sum() >= 0.3 - TOLERANCE + assert ( (1.0 * res["Conti_finite1"] + 3.0 * res["Conti_finite2"]) .ge(0.3 - TOLERANCE) From d98bf9096f266eca4e2c54cd39fc9e9848211b86 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Mon, 16 Dec 2024 11:16:17 +0100 Subject: [PATCH 27/47] Prevent using interpoint and cardinality constraints together --- baybe/constraints/validation.py | 27 ++++++++++++++++++++++++++- baybe/searchspace/continuous.py | 2 ++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/baybe/constraints/validation.py b/baybe/constraints/validation.py index f9c34f9aa..4dbb68f0e 100644 --- a/baybe/constraints/validation.py +++ b/baybe/constraints/validation.py @@ -4,7 +4,10 @@ from itertools import combinations from baybe.constraints.base import Constraint -from baybe.constraints.continuous import ContinuousCardinalityConstraint +from baybe.constraints.continuous import ( + ContinuousCardinalityConstraint, + ContinuousConstraint, +) from baybe.constraints.discrete import ( DiscreteDependenciesConstraint, ) @@ -98,3 +101,25 @@ def validate_cardinality_constraints_are_nonoverlapping( f"cannot share the same parameters. Found the following overlapping " f"parameter sets: {s1}, {s2}." ) + + +def validate_no_interpoint_and_cardinality_constraints( + constraints: Collection[ContinuousConstraint], +): + """Validate that cardinality and interpoint constraints are not used together. + + This is a current limitation in our code and might be enabled in the future. + + Args: + constraints: A collection of continuous constraints. + + Raises: + ValueError: If there are both interpoint and cardinality constraints. + """ + if any(c.is_interpoint for c in constraints) and any( + isinstance(c, ContinuousCardinalityConstraint) for c in constraints + ): + raise ValueError( + f"Cconstraints of type `{ContinuousCardinalityConstraint.__name__}` " + "cannot be used together with interpoint constraints." + ) diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index fb4c32b51..62b753abb 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -20,6 +20,7 @@ from baybe.constraints.base import ContinuousConstraint, ContinuousNonlinearConstraint from baybe.constraints.validation import ( validate_cardinality_constraints_are_nonoverlapping, + validate_no_interpoint_and_cardinality_constraints, ) from baybe.parameters import NumericalContinuousParameter from baybe.parameters.base import ContinuousParameter @@ -174,6 +175,7 @@ def from_product( ) -> SubspaceContinuous: """See :class:`baybe.searchspace.core.SearchSpace`.""" constraints = constraints or [] + validate_no_interpoint_and_cardinality_constraints(constraints) return SubspaceContinuous( parameters=[p for p in parameters if p.is_continuous], # type:ignore[misc] constraints_lin_eq=[ # type:ignore[attr-misc] From bd0c566873be92b60dd179bc5f823e455cd4ec63 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Mon, 16 Dec 2024 11:16:33 +0100 Subject: [PATCH 28/47] Add test for using interpoint and cardinality constraints --- tests/test_searchspace.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_searchspace.py b/tests/test_searchspace.py index d127e0698..840bac1c5 100644 --- a/tests/test_searchspace.py +++ b/tests/test_searchspace.py @@ -268,3 +268,30 @@ def test_cardinality_constraints_with_overlapping_parameters(): ), ), ) + + +def test_cardinality_and_interpoint_constraints(): + """Using cardinality and interpoint constraints together raises an error.""" + parameters = ( + NumericalContinuousParameter("c1", (0, 1)), + NumericalContinuousParameter("c2", (0, 1)), + ) + with pytest.raises( + ValueError, match="cannot be used together with interpoint constraints" + ): + SubspaceContinuous.from_product( + parameters=parameters, + constraints=( + ContinuousLinearConstraint( + parameters=["c_1"], + coefficients=[1], + operator="=", + rhs=1, + interpoint=True, + ), + ContinuousCardinalityConstraint( + parameters=["c1", "c2"], + max_cardinality=1, + ), + ), + ) From e36ee8aab7dcc67af0620bd70da0289ece179c02 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Mon, 16 Dec 2024 11:18:18 +0100 Subject: [PATCH 29/47] Add admonition on mixing interpoint and cardinality constraints --- docs/userguide/constraints.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/userguide/constraints.md b/docs/userguide/constraints.md index f7b161c17..a8a029115 100644 --- a/docs/userguide/constraints.md +++ b/docs/userguide/constraints.md @@ -104,6 +104,12 @@ ContinuousLinearConstraint( ) ``` +```{admonition} Mixing interpoint and cardinality constraints +:class: note +Currently, BayBE does not support to use both interpoint and cardinality constraints +within the same search space. +``` + ## Conditions Conditions are elements used within discrete constraints. While discrete constraints can operate on one or multiple parameters, a condition From a1ca1a5dc05f3db9ac99c1f8c5c674b048851650 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Thu, 9 Jan 2025 09:29:01 +0100 Subject: [PATCH 30/47] Validate interpoint and cardinality constraints in general call --- baybe/constraints/validation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/baybe/constraints/validation.py b/baybe/constraints/validation.py index 4dbb68f0e..bb3ace468 100644 --- a/baybe/constraints/validation.py +++ b/baybe/constraints/validation.py @@ -39,6 +39,7 @@ def validate_constraints( # noqa: DOC101, DOC103 validate_cardinality_constraints_are_nonoverlapping( [con for con in constraints if isinstance(con, ContinuousCardinalityConstraint)] ) + validate_no_interpoint_and_cardinality_constraints(constraints=constraints) param_names_all = [p.name for p in parameters] param_names_discrete = [p.name for p in parameters if p.is_discrete] From 4656677a8eb992a0b430eebfa73bc26804acf389 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Thu, 9 Jan 2025 09:33:02 +0100 Subject: [PATCH 31/47] Adjust outdated comment --- baybe/searchspace/continuous.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index 62b753abb..b59259f58 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -416,8 +416,7 @@ def sample_uniform(self, batch_size: int = 1) -> pd.DataFrame: if not self.is_constrained: return self._sample_from_bounds(batch_size, self.comp_rep_bounds.values) - # If there are neither cardinality nor interpoint constraints, we sample - # directly from the polytope + # If there are no cardinality constraints, we sample directly from the polytope if len(self.constraints_cardinality) == 0: return self._sample_from_polytope(batch_size, self.comp_rep_bounds.values) @@ -450,7 +449,6 @@ def _sample_from_polytope( eq_constraints, ineq_constraints = [], [] - # We start with the general constraints before going to interpoint constraints for c in [*self.constraints_lin_eq, *self.constraints_lin_ineq]: if not c.is_interpoint: param_indices, coefficients, rhs = c.to_botorch(self.parameters) From 6d13ef510714af867f783c2f3181c77281ceb121 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Thu, 9 Jan 2025 09:35:58 +0100 Subject: [PATCH 32/47] Base num_of_params on comp_rep instead of parameters --- baybe/searchspace/continuous.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index b59259f58..86474d34f 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -445,7 +445,7 @@ def _sample_from_polytope( from botorch.utils.sampling import get_polytope_samples # The number of parameters is needed at some places for adjusting indices - num_of_params = len(self.parameters) + num_of_params = len(self.comp_rep_columns) eq_constraints, ineq_constraints = [], [] From bc116995e0f3fe346cab39ff81670f6e4d29460b Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Thu, 9 Jan 2025 09:40:59 +0100 Subject: [PATCH 33/47] Fix capitalization of headings --- docs/userguide/constraints.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/userguide/constraints.md b/docs/userguide/constraints.md index a8a029115..b017d7acd 100644 --- a/docs/userguide/constraints.md +++ b/docs/userguide/constraints.md @@ -79,7 +79,7 @@ ContinuousLinearConstraint( A more detailed example can be found [here](../../examples/Constraints_Continuous/linear_constraints). -### Interpoint constraints +### Interpoint Constraints The constraints discussed so far all belong to the class of so called "intrapoint constraints". That is, they impose conditions on each individual point of a batch. @@ -104,7 +104,7 @@ ContinuousLinearConstraint( ) ``` -```{admonition} Mixing interpoint and cardinality constraints +```{admonition} Mixing Interpoint and Cardinality Constraints :class: note Currently, BayBE does not support to use both interpoint and cardinality constraints within the same search space. From d168b08cb505789f17a0e4f273b509d1bd2b89a0 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Thu, 9 Jan 2025 09:52:32 +0100 Subject: [PATCH 34/47] Fix incorrect validation --- baybe/constraints/validation.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/baybe/constraints/validation.py b/baybe/constraints/validation.py index bb3ace468..4dbc30529 100644 --- a/baybe/constraints/validation.py +++ b/baybe/constraints/validation.py @@ -39,7 +39,11 @@ def validate_constraints( # noqa: DOC101, DOC103 validate_cardinality_constraints_are_nonoverlapping( [con for con in constraints if isinstance(con, ContinuousCardinalityConstraint)] ) - validate_no_interpoint_and_cardinality_constraints(constraints=constraints) + validate_no_interpoint_and_cardinality_constraints( + constraints=[ + con for con in constraints if isinstance(con, ContinuousConstraint) + ] + ) param_names_all = [p.name for p in parameters] param_names_discrete = [p.name for p in parameters if p.is_discrete] From 70a08698b30e008d20f1b222e7dd17d8e4a7f8e9 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Thu, 9 Jan 2025 09:53:24 +0100 Subject: [PATCH 35/47] Use fixtures in cardnality and interpoint test --- tests/test_searchspace.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/test_searchspace.py b/tests/test_searchspace.py index 840bac1c5..4cb49e393 100644 --- a/tests/test_searchspace.py +++ b/tests/test_searchspace.py @@ -270,12 +270,8 @@ def test_cardinality_constraints_with_overlapping_parameters(): ) -def test_cardinality_and_interpoint_constraints(): - """Using cardinality and interpoint constraints together raises an error.""" - parameters = ( - NumericalContinuousParameter("c1", (0, 1)), - NumericalContinuousParameter("c2", (0, 1)), - ) +@pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) +def test_cardinality_and_interpoint_constraints(parameters): with pytest.raises( ValueError, match="cannot be used together with interpoint constraints" ): @@ -283,14 +279,14 @@ def test_cardinality_and_interpoint_constraints(): parameters=parameters, constraints=( ContinuousLinearConstraint( - parameters=["c_1"], + parameters=["Conti_finite1"], coefficients=[1], operator="=", rhs=1, interpoint=True, ), ContinuousCardinalityConstraint( - parameters=["c1", "c2"], + parameters=["Conti_finite1", "Conti_finite2"], max_cardinality=1, ), ), From 11a1637a3c9def4c3bc96b4148de49766db9f837 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Thu, 9 Jan 2025 11:20:13 +0100 Subject: [PATCH 36/47] Fix incorrect validation --- baybe/constraints/validation.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/baybe/constraints/validation.py b/baybe/constraints/validation.py index 4dbc30529..d30ae45c1 100644 --- a/baybe/constraints/validation.py +++ b/baybe/constraints/validation.py @@ -7,6 +7,7 @@ from baybe.constraints.continuous import ( ContinuousCardinalityConstraint, ContinuousConstraint, + ContinuousLinearConstraint, ) from baybe.constraints.discrete import ( DiscreteDependenciesConstraint, @@ -121,9 +122,18 @@ def validate_no_interpoint_and_cardinality_constraints( Raises: ValueError: If there are both interpoint and cardinality constraints. """ - if any(c.is_interpoint for c in constraints) and any( + # Check is a bit cumbersome since the is_interpoint field is currently defined + # for ContinouosLinearConstraint only as these are the only ones that can + # actually be interpoint. + has_interpoint = any( + c.is_interpoint + for c in constraints + if isinstance(c, ContinuousLinearConstraint) + ) + has_cardinality = any( isinstance(c, ContinuousCardinalityConstraint) for c in constraints - ): + ) + if has_interpoint and has_cardinality: raise ValueError( f"Cconstraints of type `{ContinuousCardinalityConstraint.__name__}` " "cannot be used together with interpoint constraints." From a535005e4c9cfd4acf8b0113d1c3183e2afed540 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Thu, 9 Jan 2025 11:40:57 +0100 Subject: [PATCH 37/47] Fix example --- examples/Constraints_Continuous/interpoint.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/Constraints_Continuous/interpoint.py b/examples/Constraints_Continuous/interpoint.py index 3e0903c6d..d22063fd2 100644 --- a/examples/Constraints_Continuous/interpoint.py +++ b/examples/Constraints_Continuous/interpoint.py @@ -17,7 +17,6 @@ import os -import numpy as np from botorch.test_functions import Rastrigin from baybe import Campaign @@ -114,9 +113,9 @@ ) print( "The sum of `x_2` across all batches is exactly 5", - np.isclose(rec["x_2"].sum(), 5), + abs(rec["x_2"].sum() - 5) < TOLERANCE, ) print( - "The sum of `2*x_3` minus the sum of `x_4` across all batches is at least >= 5", + "The sum of `2*x_3` minus the sum of `x_4` across all batches is at least >= 2.5", 2 * rec["x_3"].sum() - rec["x_4"].sum() >= 2.5 - TOLERANCE, ) From 0ca32593c6b32f7ed7c82c5ec6659489c5712457 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Thu, 9 Jan 2025 11:59:38 +0100 Subject: [PATCH 38/47] Add atol to test --- tests/constraints/test_constraints_continuous.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py index fe727c0bd..18379730a 100644 --- a/tests/constraints/test_constraints_continuous.py +++ b/tests/constraints/test_constraints_continuous.py @@ -107,7 +107,9 @@ def test_interpoint_equality_multiple_parameters(campaign, n_iterations, batch_s for batch in range(n_iterations): res_batch = res[res["BatchNr"] == batch + 1] assert np.isclose( - res_batch["Conti_finite1"].sum() + res_batch["Conti_finite2"].sum(), 0.3 + res_batch["Conti_finite1"].sum() + res_batch["Conti_finite2"].sum(), + 0.3, + atol=TOLERANCE, ) From 942dd3b118bd57c473e4ede993f07305c74a7588 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Thu, 9 Jan 2025 13:53:03 +0100 Subject: [PATCH 39/47] Use pandas chaining more consistently --- .../test_constraints_continuous.py | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py index 18379730a..da28544f8 100644 --- a/tests/constraints/test_constraints_continuous.py +++ b/tests/constraints/test_constraints_continuous.py @@ -104,13 +104,11 @@ def test_interpoint_equality_multiple_parameters(campaign, n_iterations, batch_s res = campaign.measurements print(res) - for batch in range(n_iterations): - res_batch = res[res["BatchNr"] == batch + 1] - assert np.isclose( - res_batch["Conti_finite1"].sum() + res_batch["Conti_finite2"].sum(), - 0.3, - atol=TOLERANCE, - ) + res_grouped = res.groupby("BatchNr") + interpoint_result = ( + res_grouped["Conti_finite1"].sum() + res_grouped["Conti_finite2"].sum() + ) + assert np.allclose(interpoint_result, 0.3, atol=TOLERANCE) @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) @@ -122,12 +120,13 @@ def test_geq_interpoint_inequality_multiple_parameters( run_iterations(campaign, n_iterations, batch_size, add_noise=False) res = campaign.measurements print(res) - for batch in range(n_iterations): - res_batch = res[res["BatchNr"] == batch + 1] - assert ( - 2 * res_batch["Conti_finite1"].sum() - res_batch["Conti_finite2"].sum() - >= 0.3 - TOLERANCE - ) + + res_grouped = res.groupby("BatchNr") + interpoint_result = ( + 2 * res_grouped["Conti_finite1"].sum() - res_grouped["Conti_finite2"].sum() + ) + print(f"{interpoint_result=}") + assert interpoint_result.ge(0.3 - TOLERANCE).all() @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) @@ -139,13 +138,12 @@ def test_leq_interpoint_inequality_multiple_parameters( run_iterations(campaign, n_iterations, batch_size, add_noise=False) res = campaign.measurements print(res) - for batch in range(n_iterations): - res_batch = res[res["BatchNr"] == batch + 1] - print(campaign.searchspace.constraints) - assert ( - 2 * res_batch["Conti_finite1"].sum() - res_batch["Conti_finite2"].sum() - <= 0.3 + TOLERANCE - ) + + res_grouped = res.groupby("BatchNr") + interpoint_result = ( + 2 * res_grouped["Conti_finite1"].sum() - res_grouped["Conti_finite2"].sum() + ) + assert interpoint_result.le(0.3 + TOLERANCE).all() @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) @@ -158,10 +156,8 @@ def test_interpoint_normal_mix(campaign, n_iterations, batch_size): res = campaign.measurements print(res) - for batch in range(n_iterations): - res_batch = res[res["BatchNr"] == batch + 1] - assert 2 * res_batch["Conti_finite1"].sum() >= 0.3 - TOLERANCE - + interpoint_result = 2 * res.groupby("BatchNr")["Conti_finite1"].sum() + assert interpoint_result.ge(0.3 - TOLERANCE).all() assert ( (1.0 * res["Conti_finite1"] + 3.0 * res["Conti_finite2"]) .ge(0.3 - TOLERANCE) From bd3afffbd147a70f315a9d717a547b838c85e7ac Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Thu, 9 Jan 2025 13:59:26 +0100 Subject: [PATCH 40/47] Improve code snippet in user guide --- docs/userguide/constraints.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/userguide/constraints.md b/docs/userguide/constraints.md index b017d7acd..595b0d9b9 100644 --- a/docs/userguide/constraints.md +++ b/docs/userguide/constraints.md @@ -91,17 +91,18 @@ a full batch, but there is no limit for the amount of solvent to use for a singl within that batch. They can be defined by using the `interpoint` keyword of the [`ContinuousLinearConstraint`](baybe.constraints.continuous.ContinuousLinearConstraint) -class as follows: +class. ```python from baybe.constraints import ContinuousLinearConstraint ContinuousLinearConstraint( - parameters=["x_1", "x_2"], + parameters=["SolventUsed[ml]"], operator="<=", - coefficients=[1.0, 1.0], - rhs=1, + coefficients=[1.0], + rhs=100, interpoint=True, ) + ``` ```{admonition} Mixing Interpoint and Cardinality Constraints From 4ffa97745ea779fa03ac515019cea6c91fe448d4 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Thu, 9 Jan 2025 14:07:25 +0100 Subject: [PATCH 41/47] Update hypothesis strategy for continuous linear inequalities --- tests/hypothesis_strategies/constraints.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/hypothesis_strategies/constraints.py b/tests/hypothesis_strategies/constraints.py index a1acf10c3..a2fab2b9b 100644 --- a/tests/hypothesis_strategies/constraints.py +++ b/tests/hypothesis_strategies/constraints.py @@ -236,12 +236,15 @@ def continuous_linear_constraints( ) ) rhs = draw(finite_floats()) + is_interpoint = draw(st.booleans()) # Optionally add the operator operators = operators or ["=", ">=", "<="] operator = draw(st.sampled_from(operators)) - return ContinuousLinearConstraint(parameter_names, operator, coefficients, rhs) + return ContinuousLinearConstraint( + parameter_names, operator, coefficients, rhs, is_interpoint + ) continuous_linear_equality_constraints = partial( From b6ee3237d3c404d46f847f3f5d70a2672a6ce52c Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Thu, 9 Jan 2025 14:43:39 +0100 Subject: [PATCH 42/47] Fix example after rebase --- examples/Constraints_Continuous/interpoint.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/examples/Constraints_Continuous/interpoint.py b/examples/Constraints_Continuous/interpoint.py index d22063fd2..664234b67 100644 --- a/examples/Constraints_Continuous/interpoint.py +++ b/examples/Constraints_Continuous/interpoint.py @@ -17,15 +17,15 @@ import os +import pandas as pd from botorch.test_functions import Rastrigin from baybe import Campaign from baybe.constraints import ContinuousLinearConstraint -from baybe.objectives import SingleTargetObjective from baybe.parameters import NumericalContinuousParameter from baybe.searchspace import SearchSpace from baybe.targets import NumericalTarget -from baybe.utils.botorch_wrapper import botorch_function_wrapper +from baybe.utils.dataframe import arrays_to_dataframes ### Defining the test function @@ -39,7 +39,6 @@ DIMENSION = TestFunctionClass().dim BOUNDS = TestFunction.bounds -WRAPPED_FUNCTION = botorch_function_wrapper(test_function=TestFunction) ### Creating the searchspace and the objective @@ -80,7 +79,14 @@ searchspace = SearchSpace.from_product( parameters=parameters, constraints=inter_constraints ) -objective = SingleTargetObjective(target=NumericalTarget(name="Target", mode="MIN")) +target = NumericalTarget(name="Target", mode="MIN") +objective = target.to_objective() + +### Wrap the test function as a dataframe-based lookup callable + +lookup = arrays_to_dataframes( + [p.name for p in parameters], [target.name], use_torch=True +)(TestFunction) campaign = Campaign( searchspace=searchspace, @@ -97,13 +103,9 @@ for k in range(N_ITERATIONS): rec = campaign.recommend(batch_size=BATCH_SIZE) - - target_values = [] - for index, row in rec.iterrows(): - target_values.append(WRAPPED_FUNCTION(*row.to_list())) - - rec["Target"] = target_values - campaign.add_measurements(rec) + lookup_values = lookup(rec) + measurements = pd.concat([rec, lookup_values], axis=1) + campaign.add_measurements(measurements) # Check interpoint constraints From 80dc50754114e0e9e6f46671bba3a37154881d59 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Thu, 9 Jan 2025 14:44:34 +0100 Subject: [PATCH 43/47] Fix typo in constraint definition --- examples/Constraints_Continuous/interpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/Constraints_Continuous/interpoint.py b/examples/Constraints_Continuous/interpoint.py index 664234b67..068471abc 100644 --- a/examples/Constraints_Continuous/interpoint.py +++ b/examples/Constraints_Continuous/interpoint.py @@ -55,7 +55,7 @@ # This example models the following interpoint constraints: # 1. The sum of `x_1` across all batches needs to be >= 2.5. # 2. The sum of `x_2` across all batches needs to be exactly 5. -# 3. The sum of `2*x_3` minus the sum of `x_4` across all batches needs to be >= 5. +# 3. The sum of `2*x_3` minus the sum of `x_4` across all batches needs to be >= 2.5. inter_constraints = [ @@ -69,7 +69,7 @@ parameters=["x_3", "x_4"], operator=">=", coefficients=[2, -1], - rhs=5, + rhs=2.5, interpoint=True, ), ] From 3b6583bf89341058f48d785a86dade0028c4f975 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Tue, 14 Jan 2025 13:48:46 +0100 Subject: [PATCH 44/47] Replace assert on batch_size by RuntimeError --- baybe/constraints/continuous.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py index bb0623ddb..51254c9c4 100644 --- a/baybe/constraints/continuous.py +++ b/baybe/constraints/continuous.py @@ -128,6 +128,10 @@ def to_botorch( Returns: The tuple required by botorch. + + Raises: + RuntimeError: When the constraint is an interpoint constraint but + batch_size is ``None``. """ import torch @@ -143,9 +147,11 @@ def to_botorch( coefficients = self.coefficients torch_indices = torch.tensor(param_indices) else: - assert ( - batch_size is not None - ), "No batch_size set but using interpoint constraints" + if batch_size is None: + raise RuntimeError( + "No `batch_size` set but using interpoint constraints." + "This should nothappen and means that there is a bug in the code." + ) param_index = {name: param_names.index(name) for name in self.parameters} param_indices_interpoint = [ (batch, param_index[name] + idx_offset) From 45675c7d2264da6c9f2bfd360bfc903aec3d5f15 Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Tue, 14 Jan 2025 13:50:59 +0100 Subject: [PATCH 45/47] Change name of parameter in userguide --- docs/userguide/constraints.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/userguide/constraints.md b/docs/userguide/constraints.md index 595b0d9b9..57ee5b491 100644 --- a/docs/userguide/constraints.md +++ b/docs/userguide/constraints.md @@ -96,7 +96,7 @@ class. from baybe.constraints import ContinuousLinearConstraint ContinuousLinearConstraint( - parameters=["SolventUsed[ml]"], + parameters=["SolventAmount[ml]"], operator="<=", coefficients=[1.0], rhs=100, From c322b7cc5156f1f021a4e53b21bfa8e5c2ac8ecf Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Tue, 14 Jan 2025 13:52:23 +0100 Subject: [PATCH 46/47] Add comment to remind us of interpoint handling in hypothesis test --- tests/hypothesis_strategies/constraints.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/hypothesis_strategies/constraints.py b/tests/hypothesis_strategies/constraints.py index a2fab2b9b..57296eef8 100644 --- a/tests/hypothesis_strategies/constraints.py +++ b/tests/hypothesis_strategies/constraints.py @@ -236,6 +236,8 @@ def continuous_linear_constraints( ) ) rhs = draw(finite_floats()) + # TODO We will probably want to handle interpoint constraints differently + # in the future. This comment is to remind us of this. is_interpoint = draw(st.booleans()) # Optionally add the operator From 2b8d3148ad4ea2d70f79310c8c39eee02c3eef3b Mon Sep 17 00:00:00 2001 From: "Alexander V. Hopp" Date: Tue, 14 Jan 2025 14:02:11 +0100 Subject: [PATCH 47/47] Use pandas chaining more consistently --- tests/constraints/test_constraints_continuous.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py index da28544f8..c3b89001a 100644 --- a/tests/constraints/test_constraints_continuous.py +++ b/tests/constraints/test_constraints_continuous.py @@ -78,9 +78,9 @@ def test_interpoint_equality_single_parameter(campaign, n_iterations, batch_size res = campaign.measurements print(res) - for batch in range(n_iterations): - res_batch = res[res["BatchNr"] == batch + 1] - assert np.isclose(res_batch["Conti_finite1"].sum(), 0.3) + res_grouped = res.groupby("BatchNr") + interpoint_result = res_grouped["Conti_finite1"].sum() + np.allclose(interpoint_result, 0.3, atol=TOLERANCE) @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]]) @@ -91,9 +91,9 @@ def test_interpoint_inequality_single_parameter(campaign, n_iterations, batch_si res = campaign.measurements print(res) - for batch in range(n_iterations): - res_batch = res[res["BatchNr"] == batch + 1] - assert 2 * res_batch["Conti_finite1"].sum() >= 0.3 - TOLERANCE + res_grouped = res.groupby("BatchNr") + interpoint_result = 2 * res_grouped["Conti_finite1"].sum() + assert interpoint_result.ge(0.3 - TOLERANCE).all() @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]])