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 diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py index d5d5be47e..51254c9c4 100644 --- a/baybe/constraints/continuous.py +++ b/baybe/constraints/continuous.py @@ -5,10 +5,11 @@ 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 attr.validators import in_, instance_of from attrs import define, field from baybe.constraints.base import ( @@ -45,6 +46,17 @@ 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( + alias="interpoint", default=False, validator=instance_of(bool) + ) + """Flag for defining an interpoint constraint. + + 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 def _validate_coefficients( # noqa: DOC101, DOC103 self, _: Any, coefficients: list[float] @@ -98,7 +110,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 | None = None, ) -> tuple[Tensor, Tensor, float]: """Cast the constraint in a format required by botorch. @@ -108,25 +123,48 @@ 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. + + Raises: + RuntimeError: When the constraint is an interpoint constraint but + batch_size is ``None``. """ import torch 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: + 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) + 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(), ) diff --git a/baybe/constraints/validation.py b/baybe/constraints/validation.py index f9c34f9aa..d30ae45c1 100644 --- a/baybe/constraints/validation.py +++ b/baybe/constraints/validation.py @@ -4,7 +4,11 @@ from itertools import combinations from baybe.constraints.base import Constraint -from baybe.constraints.continuous import ContinuousCardinalityConstraint +from baybe.constraints.continuous import ( + ContinuousCardinalityConstraint, + ContinuousConstraint, + ContinuousLinearConstraint, +) from baybe.constraints.discrete import ( DiscreteDependenciesConstraint, ) @@ -36,6 +40,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=[ + 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] @@ -98,3 +107,34 @@ 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. + """ + # 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." + ) diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py index 3b1c0de96..8b6bafa66 100644 --- a/baybe/recommenders/pure/bayesian/botorch.py +++ b/baybe/recommenders/pure/bayesian/botorch.py @@ -185,12 +185,12 @@ 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 ] 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 ] or None, # TODO: https://github.com/pytorch/botorch/issues/2042 @@ -234,6 +234,11 @@ def _recommend_hybrid( Returns: The recommended points. """ + 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: raise IncompatibleAcquisitionFunctionError( diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index 5fedf3f4e..86474d34f 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 @@ -83,6 +84,7 @@ def __str__(self) -> str: 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) lin_ineq_df = pd.DataFrame(ineq_constraints_list) @@ -173,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] @@ -286,6 +289,24 @@ def comp_rep_bounds(self) -> pd.DataFrame: dtype=DTypeFloatNumpy, ) + @property + def is_constrained(self) -> bool: + """Boolean flag indicating whether the subspace is constrained in any way.""" + return any( + ( + self.constraints_lin_eq, + self.constraints_lin_ineq, + self.constraints_nonlin, + ) + ) + + @property + def has_interpoint_constraints(self) -> bool: + """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 + ) + def _drop_parameters(self, parameter_names: Collection[str]) -> SubspaceContinuous: """Create a copy of the subspace with certain parameters removed. @@ -391,14 +412,11 @@ 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 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) @@ -413,22 +431,70 @@ 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( - self, batch_size: int, bounds: np.ndarray + self, + batch_size: int, + bounds: np.ndarray, ) -> pd.DataFrame: """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, + # see https://github.com/pytorch/botorch/issues/2468 + import torch 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.comp_rep_columns) + + eq_constraints, ineq_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) + 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 + } + param_indices_list = [ + batch * num_of_params + param_index[param] + for param in c.parameters + for batch in range(batch_size) + ] + _, coefficients, rhs = c.to_botorch( + parameters=self.parameters, batch_size=batch_size + ) + botorch_tuple = ( + torch.tensor(param_indices_list), + coefficients, + rhs, + ) + 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=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 - ], + 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_with_cardinality_constraints( diff --git a/docs/userguide/constraints.md b/docs/userguide/constraints.md index 61fff3e71..57ee5b491 100644 --- a/docs/userguide/constraints.md +++ b/docs/userguide/constraints.md @@ -79,6 +79,38 @@ 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. +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. +```python +from baybe.constraints import ContinuousLinearConstraint + +ContinuousLinearConstraint( + parameters=["SolventAmount[ml]"], + operator="<=", + coefficients=[1.0], + rhs=100, + interpoint=True, +) + +``` + +```{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 diff --git a/examples/Constraints_Continuous/interpoint.py b/examples/Constraints_Continuous/interpoint.py new file mode 100644 index 000000000..068471abc --- /dev/null +++ b/examples/Constraints_Continuous/interpoint.py @@ -0,0 +1,123 @@ +## 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. +# 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. + +### Necessary imports for this example + +import os + +import pandas as pd +from botorch.test_functions import Rastrigin + +from baybe import Campaign +from baybe.constraints import ContinuousLinearConstraint +from baybe.parameters import NumericalContinuousParameter +from baybe.searchspace import SearchSpace +from baybe.targets import NumericalTarget +from baybe.utils.dataframe import arrays_to_dataframes + +### 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 + +### 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 >= 2.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=2.5, + interpoint=True, + ), +] + +### Construct search space without the previous constraints + +searchspace = SearchSpace.from_product( + parameters=parameters, constraints=inter_constraints +) +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, + 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) + lookup_values = lookup(rec) + measurements = pd.concat([rec, lookup_values], axis=1) + campaign.add_measurements(measurements) + + # 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", + 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 >= 2.5", + 2 * rec["x_3"].sum() - rec["x_4"].sum() >= 2.5 - TOLERANCE, + ) diff --git a/tests/conftest.py b/tests/conftest.py index c82630cd4..49b259255 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -553,6 +553,41 @@ def custom_function(df: pd.DataFrame) -> pd.Series: coefficients=[1.0, 3.0], rhs=0.3, ), + "InterConstraint_1": ContinuousLinearConstraint( + parameters=["Conti_finite1"], + operator="=", + coefficients=[1], + rhs=0.3, + interpoint=True, + ), + "InterConstraint_2": ContinuousLinearConstraint( + parameters=["Conti_finite1"], + operator=">=", + coefficients=[2], + rhs=0.3, + interpoint=True, + ), + "InterConstraint_3": ContinuousLinearConstraint( + parameters=["Conti_finite1", "Conti_finite2"], + operator="=", + coefficients=[1, 1], + rhs=0.3, + interpoint=True, + ), + "InterConstraint_4": ContinuousLinearConstraint( + parameters=["Conti_finite1", "Conti_finite2"], + coefficients=[2, -1], + operator=">=", + 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 @@ -895,7 +930,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..c3b89001a 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"]]) @@ -68,6 +70,101 @@ 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"]]) +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) + res = campaign.measurements + print(res) + + 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"]]) +@pytest.mark.parametrize("constraint_names", [["InterConstraint_2"]]) +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) + res = campaign.measurements + print(res) + + 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"]]) +@pytest.mark.parametrize("constraint_names", [["InterConstraint_3"]]) +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) + res = campaign.measurements + print(res) + + 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"]]) +@pytest.mark.parametrize("constraint_names", [["InterConstraint_4"]]) +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) + + 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"]]) +@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) + + 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"]]) +@pytest.mark.parametrize( + "constraint_names", [["ContiConstraint_4", "InterConstraint_2"]] +) +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) + + 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) + .all() + ) + + @pytest.mark.slow @pytest.mark.parametrize( "parameter_names", diff --git a/tests/hypothesis_strategies/constraints.py b/tests/hypothesis_strategies/constraints.py index a1acf10c3..57296eef8 100644 --- a/tests/hypothesis_strategies/constraints.py +++ b/tests/hypothesis_strategies/constraints.py @@ -236,12 +236,17 @@ 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 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( diff --git a/tests/test_searchspace.py b/tests/test_searchspace.py index d127e0698..4cb49e393 100644 --- a/tests/test_searchspace.py +++ b/tests/test_searchspace.py @@ -268,3 +268,26 @@ def test_cardinality_constraints_with_overlapping_parameters(): ), ), ) + + +@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" + ): + SubspaceContinuous.from_product( + parameters=parameters, + constraints=( + ContinuousLinearConstraint( + parameters=["Conti_finite1"], + coefficients=[1], + operator="=", + rhs=1, + interpoint=True, + ), + ContinuousCardinalityConstraint( + parameters=["Conti_finite1", "Conti_finite2"], + max_cardinality=1, + ), + ), + )