From d8112405e786380c26e7aedddde5717370ca4d3c Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Tue, 2 Jul 2024 11:10:50 +0200
Subject: [PATCH 001/108] Enable cardinality constraint in botorch recommender
 via sampling inactive parameters

---
 baybe/recommenders/pure/bayesian/botorch.py   | 69 ++++++++++++-----
 baybe/searchspace/continuous.py               | 74 +++++++++++++++++++
 .../test_cardinality_constraint_continuous.py | 50 ++++---------
 .../test_constraints_continuous.py            | 33 +++++++++
 4 files changed, 173 insertions(+), 53 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index a3601c3fa..07703b936 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -21,6 +21,8 @@
     sample_numerical_df,
 )
 
+N_RESTART_CARDINALITY = 5
+
 
 @define(kw_only=True)
 class BotorchRecommender(BayesianRecommender):
@@ -153,25 +155,56 @@ def _recommend_continuous(
 
         import torch
         from botorch.optim import optimize_acqf
+        from torch import Tensor
+
+        def _recommend_continuous_on_subspace(
+            _subspace_continuous: SubspaceContinuous
+        ) -> tuple[Tensor, Tensor]:
+            """Define a helper function with only one parameter."""
+            _points, _acqf_values = optimize_acqf(
+                acq_function=self._botorch_acqf,
+                bounds=torch.from_numpy(_subspace_continuous.param_bounds_comp),
+                q=batch_size,
+                num_restarts=5,  # TODO make choice for num_restarts
+                raw_samples=10,  # TODO make choice for raw_samples
+                equality_constraints=[
+                    c.to_botorch(_subspace_continuous.parameters)
+                    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)
+                    for c in _subspace_continuous.constraints_lin_ineq
+                ]
+                or None,  # TODO: https://github.com/pytorch/botorch/issues/2042
+                sequential=self.sequential_continuous,
+            )
+            return _points, _acqf_values
+
+        if len(subspace_continuous.constraints_cardinality):
+            acqf_values_all: list[Tensor] = []
+            points_all: list[Tensor] = []
+            for _ in range(N_RESTART_CARDINALITY):
+                # Randomly set some parameters inactive
+                inactive_params_sample = (
+                    subspace_continuous._sample_inactive_parameters(1)[0]
+                )
+                # Create a new subspace
+                subspace_renewed = subspace_continuous._ensure_nonzero_parameters(
+                    inactive_params_sample
+                )
 
-        points, _ = optimize_acqf(
-            acq_function=self._botorch_acqf,
-            bounds=torch.from_numpy(subspace_continuous.param_bounds_comp),
-            q=batch_size,
-            num_restarts=5,  # TODO make choice for num_restarts
-            raw_samples=10,  # TODO make choice for raw_samples
-            equality_constraints=[
-                c.to_botorch(subspace_continuous.parameters)
-                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)
-                for c in subspace_continuous.constraints_lin_ineq
-            ]
-            or None,  # TODO: https://github.com/pytorch/botorch/issues/2042
-            sequential=self.sequential_continuous,
-        )
+                (
+                    points_all_i,
+                    acqf_values_i,
+                ) = _recommend_continuous_on_subspace(
+                    subspace_renewed,
+                )
+                points_all.append(points_all_i.unsqueeze(0))
+                acqf_values_all.append(acqf_values_i.unsqueeze(0))
+            points = torch.cat(points_all)[torch.argmax(torch.cat(acqf_values_all)), :]
+        else:
+            points, _ = _recommend_continuous_on_subspace(subspace_continuous)
 
         # Return optimized points as dataframe
         rec = pd.DataFrame(points, columns=subspace_continuous.param_names)
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 5b687d668..0bdc7eb9d 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -36,6 +36,7 @@
     from baybe.searchspace.core import SearchSpace
 
 _MAX_CARDINALITY_SAMPLING_ATTEMPTS = 10_000
+ZERO_THRESHOLD = 1e-5
 
 
 @define
@@ -247,6 +248,14 @@ def param_names(self) -> tuple[str, ...]:
         """Return list of parameter names."""
         return tuple(p.name for p in self.parameters)
 
+    @property
+    def param_names_in_cardinality_constraint(self) -> tuple[str, ...]:
+        """Return list of parameter names involved in cardinality constraints."""
+        params_per_cardinatliy_constraint = [
+            c.parameters for c in self.constraints_cardinality
+        ]
+        return tuple(chain(*params_per_cardinatliy_constraint))
+
     @property
     def param_bounds_comp(self) -> np.ndarray:
         """Return bounds as numpy array."""
@@ -454,6 +463,71 @@ def _sample_inactive_parameters(self, batch_size: int = 1) -> list[set[str]]:
         ]
         return [set(chain(*x)) for x in zip(*inactives_per_constraint)]
 
+    def _ensure_nonzero_parameters(
+        self,
+        inactive_parameters: Collection[str],
+        zero_threshold: float = ZERO_THRESHOLD,
+    ) -> SubspaceContinuous:
+        """Create a new subspace with following several actions.
+
+        * Ensure inactive parameter = 0.0.
+        * Ensure active parameter != 0.0.
+        * Remove cardinality constraint.
+
+        Args:
+            inactive_parameters: A list of inactive parameters.
+            zero_threshold: Threshold for checking whether a value is zero.
+
+        Returns:
+            A new subspace object.
+        """
+        # Active parameters: parameters involved in cardinality constraints
+        active_params_sample = set(
+            self.param_names_in_cardinality_constraint
+        ).difference(set(inactive_parameters))
+
+        constraints_lin_ineq = list(self.constraints_lin_ineq)
+        for active_param in active_params_sample:
+            index = self.param_names.index(active_param)
+
+            # Ensure x != 0 when bounds = [..., 0]. This is needed, otherwise
+            # the minimum cardinality constraint is easily violated
+            # TODO: Ensure x != 0 when x in [..., 0, ...] is not done
+            # TODO: To ensure the minimum cardinaltiy constraints, shall we keep the x
+            #  != 0 operations or shall we have instead skip the invalid results
+            if self.parameters[index].bounds.upper == 0:
+                constraints_lin_ineq.append(
+                    ContinuousLinearInequalityConstraint(
+                        parameters=[active_param],
+                        coefficients=[-1.0],
+                        rhs=min(zero_threshold, -self.parameters[index].bounds.lower),
+                    )
+                )
+            # Ensure x != 0 when bounds = [0, ...]
+            elif self.parameters[index].bounds.lower == 0:
+                constraints_lin_ineq.append(
+                    ContinuousLinearInequalityConstraint(
+                        parameters=[active_param],
+                        coefficients=[1.0],
+                        rhs=min(zero_threshold, self.parameters[index].bounds.upper),
+                    ),
+                )
+
+        # Ensure inactive parameters must be 0
+        constraints_lin_eq = list(self.constraints_lin_eq)
+        for inactive_param in inactive_parameters:
+            constraints_lin_eq.append(
+                ContinuousLinearEqualityConstraint(
+                    parameters=[inactive_param], coefficients=[1.0], rhs=0.0
+                )
+            )
+
+        return SubspaceContinuous(
+            parameters=tuple(self.parameters),
+            constraints_lin_eq=tuple(constraints_lin_eq),
+            constraints_lin_ineq=tuple(constraints_lin_ineq),
+        )
+
     def samples_full_factorial(self, n_points: int = 1) -> pd.DataFrame:
         """Deprecated!"""  # noqa: D401
         warnings.warn(
diff --git a/tests/constraints/test_cardinality_constraint_continuous.py b/tests/constraints/test_cardinality_constraint_continuous.py
index 0d45f5a5f..7afb60b7e 100644
--- a/tests/constraints/test_cardinality_constraint_continuous.py
+++ b/tests/constraints/test_cardinality_constraint_continuous.py
@@ -11,13 +11,16 @@
     ContinuousLinearEqualityConstraint,
     ContinuousLinearInequalityConstraint,
 )
-from baybe.parameters import NumericalContinuousParameter
-from baybe.recommenders.pure.nonpredictive.sampling import RandomRecommender
+from baybe.parameters.numerical import NumericalContinuousParameter
 from baybe.searchspace.core import SearchSpace, SubspaceContinuous
 
 
 def _validate_samples(
-    samples: pd.DataFrame, max_cardinality: int, min_cardinality: int, batch_size: int
+    samples: pd.DataFrame,
+    max_cardinality: int,
+    min_cardinality: int,
+    batch_size: int,
+    threshold: float = 0.0,
 ):
     """Validate if cardinality-constrained samples fulfill the necessary conditions.
 
@@ -31,16 +34,21 @@ def _validate_samples(
         max_cardinality: Maximum allowed cardinality
         min_cardinality: Minimum required cardinality
         batch_size: Requested batch size
+        threshold: Threshold for checking whether a value is treated as zero.
     """
     # Assert that cardinality constraint is fulfilled
-    n_nonzero = np.sum(~np.isclose(samples, 0.0), axis=1)
+    n_nonzero = np.sum(samples.abs().ge(threshold), axis=1)
+    # n_nonzero = np.sum(~np.isclose(samples, 0.0, rtol=threshold), axis=1)
     assert np.all(n_nonzero >= min_cardinality) and np.all(n_nonzero <= max_cardinality)
 
     # Assert that we obtain as many samples as requested
-    assert len(samples) == batch_size
+    assert samples.shape[0] == batch_size
 
-    # If there are duplicates, they must all come from the case cardinality = 0
-    assert np.all(samples[samples.duplicated()] == 0.0)
+    # If all rows are duplicates of the first row, they must all come from the case
+    # cardinality = 0 (all rows are zeros)
+    all_zero_rows = (samples == 0).all(axis=1)
+    duplicated_rows = samples.duplicated()
+    assert ~np.all(duplicated_rows[1:]) | np.all(all_zero_rows)
 
 
 # Combinations of cardinalities to be tested
@@ -138,31 +146,3 @@ def test_polytope_sampling_with_cardinality_constraint():
         .ge(rhs_inequality - TOLERANCE)
         .all()
     )
-
-
-@pytest.mark.parametrize(
-    "parameter_names", [["Conti_finite1", "Conti_finite2", "Conti_finite3"]]
-)
-@pytest.mark.parametrize("constraint_names", [["ContiConstraint_5"]])
-@pytest.mark.parametrize("batch_size", [5], ids=["b5"])
-def test_random_recommender_with_cardinality_constraint(
-    parameters: list[NumericalContinuousParameter],
-    constraints: list[ContinuousCardinalityConstraint],
-    batch_size: int,
-):
-    """Recommendations generated by a `RandomRecommender` under a cardinality constraint
-    have the expected number of nonzero elements."""  # noqa
-
-    searchspace = SearchSpace.from_product(
-        parameters=parameters, constraints=constraints
-    )
-    recommender = RandomRecommender()
-    recommendations = recommender.recommend(
-        searchspace=searchspace,
-        batch_size=batch_size,
-    )
-
-    # Assert that conditions listed in_validate_samples() are fulfilled
-    _validate_samples(
-        recommendations, max_cardinality=2, min_cardinality=1, batch_size=batch_size
-    )
diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py
index 6b850b3c7..8d03726ff 100644
--- a/tests/constraints/test_constraints_continuous.py
+++ b/tests/constraints/test_constraints_continuous.py
@@ -7,7 +7,9 @@
     ContinuousLinearEqualityConstraint,
     ContinuousLinearInequalityConstraint,
 )
+from baybe.searchspace.continuous import ZERO_THRESHOLD
 from tests.conftest import run_iterations
+from tests.constraints.test_cardinality_constraint_continuous import _validate_samples
 
 
 @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]])
@@ -58,6 +60,37 @@ def test_inequality2(campaign, n_iterations, batch_size):
     assert (1.0 * res["Conti_finite1"] + 3.0 * res["Conti_finite2"]).ge(0.299).all()
 
 
+@pytest.mark.slow
+@pytest.mark.parametrize(
+    "parameter_names", [["Conti_finite1", "Conti_finite2", "Conti_finite3"]]
+)
+@pytest.mark.parametrize("constraint_names", [["ContiConstraint_5"]])
+@pytest.mark.parametrize("batch_size", [5], ids=["b5"])
+def test_cardinality_constraint(campaign, n_iterations, batch_size):
+    """Test cardinality constraint for both random recommender and botorch
+    recommender."""  # noqa
+
+    MIN_CARDINALITY = 0
+    MAX_CARDINALITY = 2
+    run_iterations(campaign, n_iterations, batch_size, add_noise=False)
+    recommendations = campaign.measurements
+
+    print(recommendations)
+
+    # Assert that conditions listed in_validate_samples() are fulfilled
+    for i_batch in range(2):
+        _validate_samples(
+            recommendations.loc[
+                0 + i_batch * batch_size : (i_batch + 1) * batch_size - 1,
+                ["Conti_finite1", "Conti_finite2", "Conti_finite3"],
+            ],
+            max_cardinality=MAX_CARDINALITY,
+            min_cardinality=MIN_CARDINALITY,
+            batch_size=batch_size,
+            threshold=ZERO_THRESHOLD,
+        )
+
+
 @pytest.mark.slow
 @pytest.mark.parametrize(
     "parameter_names",

From da813f5b64d2747e5d51bb6539e21946a51b3fbc Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Tue, 2 Jul 2024 15:00:12 +0200
Subject: [PATCH 002/108] Make inactive parameters fixed features

---
 baybe/recommenders/pure/bayesian/botorch.py | 20 ++++++++++++++++++--
 baybe/searchspace/continuous.py             | 18 ++++--------------
 2 files changed, 22 insertions(+), 16 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 07703b936..c531c4833 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -158,15 +158,17 @@ def _recommend_continuous(
         from torch import Tensor
 
         def _recommend_continuous_on_subspace(
-            _subspace_continuous: SubspaceContinuous
+            _subspace_continuous: SubspaceContinuous,
+            _fixed_parameters: dict[int, float] | None = None,
         ) -> tuple[Tensor, Tensor]:
-            """Define a helper function with only one parameter."""
+            """Define a helper function on a subset of parameters."""
             _points, _acqf_values = optimize_acqf(
                 acq_function=self._botorch_acqf,
                 bounds=torch.from_numpy(_subspace_continuous.param_bounds_comp),
                 q=batch_size,
                 num_restarts=5,  # TODO make choice for num_restarts
                 raw_samples=10,  # TODO make choice for raw_samples
+                fixed_features=_fixed_parameters,
                 equality_constraints=[
                     c.to_botorch(_subspace_continuous.parameters)
                     for c in _subspace_continuous.constraints_lin_eq
@@ -189,6 +191,19 @@ def _recommend_continuous_on_subspace(
                 inactive_params_sample = (
                     subspace_continuous._sample_inactive_parameters(1)[0]
                 )
+
+                if len(inactive_params_sample):
+                    # Turn inactive parameters to fixed features (used as input in
+                    # optimize_acqf())
+                    indices_inactive_params = [
+                        subspace_continuous.param_names.index(key)
+                        for key in subspace_continuous.param_names
+                        if key in inactive_params_sample
+                    ]
+                    fixed_parameters = {ind: 0.0 for ind in indices_inactive_params}
+                else:
+                    fixed_parameters = None
+
                 # Create a new subspace
                 subspace_renewed = subspace_continuous._ensure_nonzero_parameters(
                     inactive_params_sample
@@ -199,6 +214,7 @@ def _recommend_continuous_on_subspace(
                     acqf_values_i,
                 ) = _recommend_continuous_on_subspace(
                     subspace_renewed,
+                    fixed_parameters,
                 )
                 points_all.append(points_all_i.unsqueeze(0))
                 acqf_values_all.append(acqf_values_i.unsqueeze(0))
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 0bdc7eb9d..4c49eae19 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -470,7 +470,6 @@ def _ensure_nonzero_parameters(
     ) -> SubspaceContinuous:
         """Create a new subspace with following several actions.
 
-        * Ensure inactive parameter = 0.0.
         * Ensure active parameter != 0.0.
         * Remove cardinality constraint.
 
@@ -490,11 +489,11 @@ def _ensure_nonzero_parameters(
         for active_param in active_params_sample:
             index = self.param_names.index(active_param)
 
+            # TODO: Ensure x != 0 when x in [..., 0, ...] is not done. Do we need it?
+            # TODO: To ensure the minimum cardinality constraints, shall we keep the x
+            #  != 0 operations or shall we instead skip the invalid results at the end
             # Ensure x != 0 when bounds = [..., 0]. This is needed, otherwise
             # the minimum cardinality constraint is easily violated
-            # TODO: Ensure x != 0 when x in [..., 0, ...] is not done
-            # TODO: To ensure the minimum cardinaltiy constraints, shall we keep the x
-            #  != 0 operations or shall we have instead skip the invalid results
             if self.parameters[index].bounds.upper == 0:
                 constraints_lin_ineq.append(
                     ContinuousLinearInequalityConstraint(
@@ -513,18 +512,9 @@ def _ensure_nonzero_parameters(
                     ),
                 )
 
-        # Ensure inactive parameters must be 0
-        constraints_lin_eq = list(self.constraints_lin_eq)
-        for inactive_param in inactive_parameters:
-            constraints_lin_eq.append(
-                ContinuousLinearEqualityConstraint(
-                    parameters=[inactive_param], coefficients=[1.0], rhs=0.0
-                )
-            )
-
         return SubspaceContinuous(
             parameters=tuple(self.parameters),
-            constraints_lin_eq=tuple(constraints_lin_eq),
+            constraints_lin_eq=self.constraints_lin_eq,
             constraints_lin_ineq=tuple(constraints_lin_ineq),
         )
 

From adf5cc2c3daa7979de9c5dde5c1ae53a81294e3f Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Thu, 4 Jul 2024 11:06:55 +0200
Subject: [PATCH 003/108] Fix bug in test file

---
 .../test_cardinality_constraint_continuous.py            | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/tests/constraints/test_cardinality_constraint_continuous.py b/tests/constraints/test_cardinality_constraint_continuous.py
index 7afb60b7e..765084131 100644
--- a/tests/constraints/test_cardinality_constraint_continuous.py
+++ b/tests/constraints/test_cardinality_constraint_continuous.py
@@ -37,8 +37,13 @@ def _validate_samples(
         threshold: Threshold for checking whether a value is treated as zero.
     """
     # Assert that cardinality constraint is fulfilled
-    n_nonzero = np.sum(samples.abs().ge(threshold), axis=1)
-    # n_nonzero = np.sum(~np.isclose(samples, 0.0, rtol=threshold), axis=1)
+    if threshold == 0.0:
+        # When threshold is zero, abs(value) > threshold is treated as non-zero.
+        n_nonzero = len(samples.columns) - np.sum(samples.abs().le(threshold), axis=1)
+    else:
+        # When threshold is non-zero, abs(value) >= threshold is treated as non-zero.
+        n_nonzero = np.sum(samples.abs().ge(threshold), axis=1)
+
     assert np.all(n_nonzero >= min_cardinality) and np.all(n_nonzero <= max_cardinality)
 
     # Assert that we obtain as many samples as requested

From e69ceffaa3757f7900031367600eb29e837f0e45 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Tue, 2 Jul 2024 17:08:19 +0200
Subject: [PATCH 004/108] Validate bounds of cardinality constraint parameters

---
 baybe/constraints/validation.py | 45 ++++++++++++++++++++++++++++++++-
 baybe/searchspace/continuous.py |  4 +++
 tests/test_searchspace.py       | 19 ++++++++++++++
 3 files changed, 67 insertions(+), 1 deletion(-)

diff --git a/baybe/constraints/validation.py b/baybe/constraints/validation.py
index f9c34f9aa..f8c872414 100644
--- a/baybe/constraints/validation.py
+++ b/baybe/constraints/validation.py
@@ -1,6 +1,6 @@
 """Validation functionality for constraints."""
 
-from collections.abc import Collection
+from collections.abc import Collection, Sequence
 from itertools import combinations
 
 from baybe.constraints.base import Constraint
@@ -8,6 +8,7 @@
 from baybe.constraints.discrete import (
     DiscreteDependenciesConstraint,
 )
+from baybe.parameters import NumericalContinuousParameter
 from baybe.parameters.base import Parameter
 
 
@@ -26,6 +27,8 @@ def validate_constraints(  # noqa: DOC101, DOC103
         ValueError: If any discrete constraint includes a continuous parameter.
         ValueError: If any discrete constraint that is valid only for numerical
             discrete parameters includes non-numerical discrete parameters.
+        ValueError: If the bounds of any parameter in a cardinality constraint does
+            not cover zero.
     """
     if sum(isinstance(itm, DiscreteDependenciesConstraint) for itm in constraints) > 1:
         raise ValueError(
@@ -41,6 +44,9 @@ def validate_constraints(  # noqa: DOC101, DOC103
     param_names_discrete = [p.name for p in parameters if p.is_discrete]
     param_names_continuous = [p.name for p in parameters if p.is_continuous]
     param_names_non_numerical = [p.name for p in parameters if not p.is_numerical]
+    params_continuous: list[NumericalContinuousParameter] = [
+        p for p in parameters if p.is_continuous
+    ]
 
     for constraint in constraints:
         if not all(p in param_names_all for p in constraint.parameters):
@@ -78,6 +84,11 @@ def validate_constraints(  # noqa: DOC101, DOC103
                 f"Parameter list of the affected constraint: {constraint.parameters}."
             )
 
+        if isinstance(constraint, ContinuousCardinalityConstraint):
+            validate_parameters_bounds_in_cardinality_constraint(
+                params_continuous, constraint
+            )
+
 
 def validate_cardinality_constraints_are_nonoverlapping(
     constraints: Collection[ContinuousCardinalityConstraint],
@@ -98,3 +109,35 @@ def validate_cardinality_constraints_are_nonoverlapping(
                 f"cannot share the same parameters. Found the following overlapping "
                 f"parameter sets: {s1}, {s2}."
             )
+
+
+def validate_parameters_bounds_in_cardinality_constraint(
+    parameters: Sequence[NumericalContinuousParameter],
+    constraint: ContinuousCardinalityConstraint,
+) -> None:
+    """Validate that the bounds of all parameters in a cardinality constraint cover
+    zero.
+
+    Args:
+        parameters: A collection of continuous numerical parameters.
+        constraint: A continuous cardinality constraint.
+
+    Raises:
+        ValueError: If the bounds of any parameter of a constraint does not cover zero.
+    """  # noqa D205
+    param_names = [p.name for p in parameters]
+    for param_in_constraint in constraint.parameters:
+        # Note that this implementation checks implicitly that all constraint
+        # parameters must be included in the list of parameters. Otherwise Runtime
+        # error occurs.
+        if (
+            param := parameters[param_names.index(param_in_constraint)]
+        ) and not param.is_in_range(0.0):
+            raise ValueError(
+                f"The bounds of all parameters in a constraint of type "
+                f"`{ContinuousCardinalityConstraint.__name__}` must cover "
+                f"zero. Either correct the parameter ({param}) bounds:"
+                f" {param.bounds=} or remove the parameter {param} from the "
+                f"{constraint=} and update the minimum/maximum cardinality "
+                f"accordingly."
+            )
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 4c49eae19..cae12caac 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -19,6 +19,7 @@
 from baybe.constraints.base import ContinuousConstraint, ContinuousNonlinearConstraint
 from baybe.constraints.validation import (
     validate_cardinality_constraints_are_nonoverlapping,
+    validate_parameters_bounds_in_cardinality_constraint,
 )
 from baybe.parameters import NumericalContinuousParameter
 from baybe.parameters.base import ContinuousParameter
@@ -118,6 +119,9 @@ def _validate_constraints_nonlin(self, _, __) -> None:
             self.constraints_cardinality
         )
 
+        for con in self.constraints_cardinality:
+            validate_parameters_bounds_in_cardinality_constraint(self.parameters, con)
+
     def to_searchspace(self) -> SearchSpace:
         """Turn the subspace into a search space with no discrete part."""
         from baybe.searchspace.core import SearchSpace
diff --git a/tests/test_searchspace.py b/tests/test_searchspace.py
index 71190f83d..73359943f 100644
--- a/tests/test_searchspace.py
+++ b/tests/test_searchspace.py
@@ -281,3 +281,22 @@ def test_cardinality_constraints_with_overlapping_parameters():
                 ),
             ),
         )
+
+
+def test_cardinality_constraint_with_invalid_parameter_bounds():
+    """Impose a cardinality constraint on a parameter whose valid area does not
+    include zero raises an error."""  # noqa
+    parameters = (
+        NumericalContinuousParameter("c1", (0, 1)),
+        NumericalContinuousParameter("c2", (1, 2)),
+    )
+    with pytest.raises(ValueError, match="must cover zero"):
+        SubspaceContinuous(
+            parameters=parameters,
+            constraints_nonlin=(
+                ContinuousCardinalityConstraint(
+                    parameters=["c1", "c2"],
+                    max_cardinality=1,
+                ),
+            ),
+        )

From 2270350c369cbeaf1e37c39551aea1af040208e3 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Wed, 3 Jul 2024 09:36:25 +0200
Subject: [PATCH 005/108] Add second option: iterate through combinatorial list

---
 baybe/constraints/continuous.py             | 24 ++++++++++++++
 baybe/recommenders/pure/bayesian/botorch.py | 33 ++++++++++++++++---
 baybe/searchspace/continuous.py             | 35 +++++++++++++++++++--
 3 files changed, 85 insertions(+), 7 deletions(-)

diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py
index db128e012..089c3df06 100644
--- a/baybe/constraints/continuous.py
+++ b/baybe/constraints/continuous.py
@@ -1,6 +1,8 @@
 """Continuous constraints."""
 
 import math
+from itertools import combinations
+from math import comb
 
 import numpy as np
 from attrs import define
@@ -46,6 +48,28 @@ class ContinuousCardinalityConstraint(
 ):
     """Class for continuous cardinality constraints."""
 
+    @property
+    def combinatorial_counts_zero_parameters(self) -> int:
+        """Return the total number of all possible combinations of zero parameters."""
+        combinatorial_counts = 0
+        for i_zeros in range(
+            len(self.parameters) - self.max_cardinality,
+            len(self.parameters) - self.min_cardinality + 1,
+        ):
+            combinatorial_counts += comb(len(self.parameters), i_zeros)
+        return combinatorial_counts
+
+    @property
+    def combinatorial_zero_parameters(self) -> list[tuple[str, ...]]:
+        """Return a combinatorial list of all possible zero parameters."""
+        combinatorial_zeros = []
+        for i_zeros in range(
+            len(self.parameters) - self.max_cardinality,
+            len(self.parameters) - self.min_cardinality + 1,
+        ):
+            combinatorial_zeros.extend(combinations(self.parameters, i_zeros))
+        return combinatorial_zeros
+
     def sample_inactive_parameters(self, batch_size: int = 1) -> list[set[str]]:
         """Sample sets of inactive parameters according to the cardinality constraints.
 
diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index c531c4833..fdb9be536 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -21,7 +21,7 @@
     sample_numerical_df,
 )
 
-N_RESTART_CARDINALITY = 5
+N_ITER_THRESHOLD = 10
 
 
 @define(kw_only=True)
@@ -186,11 +186,34 @@ def _recommend_continuous_on_subspace(
         if len(subspace_continuous.constraints_cardinality):
             acqf_values_all: list[Tensor] = []
             points_all: list[Tensor] = []
-            for _ in range(N_RESTART_CARDINALITY):
-                # Randomly set some parameters inactive
-                inactive_params_sample = (
-                    subspace_continuous._sample_inactive_parameters(1)[0]
+
+            # When the size of the full list of inactive parameters is not too large,
+            # we can iterate through the full list; otherwise we randomly set some
+            # parameters inactive.
+            _iterator = (
+                subspace_continuous.combinatorial_zero_parameters
+                if (
+                    combinatorial_counts
+                    := subspace_continuous.combinatorial_counts_zero_parameters
                 )
+                <= N_ITER_THRESHOLD
+                else range(N_ITER_THRESHOLD)
+            )
+
+            for inactive_params_generator in _iterator:
+                if combinatorial_counts <= N_ITER_THRESHOLD:
+                    # Iterate through the combinations of all possible inactive
+                    # parameters.
+                    inactive_params_sample = {
+                        param
+                        for sublist in inactive_params_generator
+                        for param in sublist
+                    }
+                else:
+                    # Randomly set some parameters inactive
+                    inactive_params_sample = (
+                        subspace_continuous._sample_inactive_parameters(1)[0]
+                    )
 
                 if len(inactive_params_sample):
                     # Turn inactive parameters to fixed features (used as input in
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index cae12caac..885467419 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -3,8 +3,9 @@
 from __future__ import annotations
 
 import warnings
-from collections.abc import Collection, Sequence
-from itertools import chain
+from collections.abc import Collection, Iterable, Sequence
+from functools import reduce
+from itertools import chain, product
 from typing import TYPE_CHECKING, Any, cast
 
 import numpy as np
@@ -111,6 +112,36 @@ def constraints_cardinality(self) -> tuple[ContinuousCardinalityConstraint, ...]
             if isinstance(c, ContinuousCardinalityConstraint)
         )
 
+    @property
+    def combinatorial_counts_zero_parameters(self) -> int:
+        """Return the total number of all possible combinations of zero parameters."""
+        # Note that both continuous subspace and continuous cardinality constraint
+        # have this property. This property is the counts for the subspace
+        # parameters; while the latter one is the counts only for that constraint.
+        if self.constraints_cardinality:
+            return reduce(
+                lambda x, y: x * y,
+                [
+                    con.combinatorial_counts_zero_parameters
+                    for con in self.constraints_cardinality
+                ],
+            )
+        else:
+            return 0
+
+    @property
+    def combinatorial_zero_parameters(self) -> Iterable[tuple[str, ...]]:
+        """Return a combinatorial list of all possible zero parameters on subspace."""
+        # The comments on the difference in `combinatorial_counts_zero_parameters`
+        # applies here as well.
+        if self.constraints_cardinality:
+            return product(
+                *[
+                    con.combinatorial_zero_parameters
+                    for con in self.constraints_cardinality
+                ]
+            )
+
     @constraints_nonlin.validator
     def _validate_constraints_nonlin(self, _, __) -> None:
         """Validate nonlinear constraints."""

From 6483e4bc1a7cd294d2f1aec3542c30586903cc77 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Wed, 3 Jul 2024 21:53:36 +0200
Subject: [PATCH 006/108] Fix type error

---
 baybe/constraints/continuous.py             |  2 +-
 baybe/constraints/validation.py             |  2 +-
 baybe/recommenders/pure/bayesian/botorch.py | 51 ++++++++++++++-------
 baybe/searchspace/continuous.py             |  6 ++-
 4 files changed, 41 insertions(+), 20 deletions(-)

diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py
index 089c3df06..6a97e9661 100644
--- a/baybe/constraints/continuous.py
+++ b/baybe/constraints/continuous.py
@@ -62,7 +62,7 @@ def combinatorial_counts_zero_parameters(self) -> int:
     @property
     def combinatorial_zero_parameters(self) -> list[tuple[str, ...]]:
         """Return a combinatorial list of all possible zero parameters."""
-        combinatorial_zeros = []
+        combinatorial_zeros: list[tuple[str, ...]] = []
         for i_zeros in range(
             len(self.parameters) - self.max_cardinality,
             len(self.parameters) - self.min_cardinality + 1,
diff --git a/baybe/constraints/validation.py b/baybe/constraints/validation.py
index f8c872414..61512fac6 100644
--- a/baybe/constraints/validation.py
+++ b/baybe/constraints/validation.py
@@ -45,7 +45,7 @@ def validate_constraints(  # noqa: DOC101, DOC103
     param_names_continuous = [p.name for p in parameters if p.is_continuous]
     param_names_non_numerical = [p.name for p in parameters if not p.is_numerical]
     params_continuous: list[NumericalContinuousParameter] = [
-        p for p in parameters if p.is_continuous
+        p for p in parameters if isinstance(p, NumericalContinuousParameter)
     ]
 
     for constraint in constraints:
diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index fdb9be536..e86e9fc93 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -1,6 +1,7 @@
 """Botorch recommender."""
 
 import math
+from collections.abc import Iterable
 from typing import Any, ClassVar
 
 import pandas as pd
@@ -142,6 +143,7 @@ def _recommend_continuous(
         Raises:
             NoMCAcquisitionFunctionError: If a non-Monte Carlo acquisition function is
                 used with a batch size > 1.
+            RuntimeError: If the combinatorial list of inactive parameters is None.
 
         Returns:
             A dataframe containing the recommendations as individual rows.
@@ -187,21 +189,41 @@ def _recommend_continuous_on_subspace(
             acqf_values_all: list[Tensor] = []
             points_all: list[Tensor] = []
 
-            # When the size of the full list of inactive parameters is not too large,
-            # we can iterate through the full list; otherwise we randomly set some
-            # parameters inactive.
-            _iterator = (
-                subspace_continuous.combinatorial_zero_parameters
-                if (
-                    combinatorial_counts
-                    := subspace_continuous.combinatorial_counts_zero_parameters
+            # The key steps of handling cardinality constraint are
+            # * Determine several configurations of inactive parameters based on the
+            # cardinality constraints.
+            # * Optimize the acquisition function for different configurations and
+            # pick the best one.
+            # There are two mechanisms for inactive parameter configurations. The
+            # full list of different inactive parameter configurations is used,
+            # when its size is not too large; otherwise we randomly pick a
+            # fixed number of inactive parameter configurations.
+
+            # Create an iterable that either iterates through range() or iterates
+            # through the full list configuration.
+            if (
+                subspace_continuous.combinatorial_counts_zero_parameters
+                > N_ITER_THRESHOLD
+            ):
+                _iterator: Iterable[tuple[tuple[str, ...], ...]] | range = range(
+                    N_ITER_THRESHOLD
+                )
+            elif subspace_continuous.combinatorial_zero_parameters is not None:
+                _iterator = subspace_continuous.combinatorial_zero_parameters
+            else:
+                raise RuntimeError(
+                    f"The attribute"
+                    f"{SubspaceContinuous.combinatorial_zero_parameters.__name__}"
+                    f"should not be None."
                 )
-                <= N_ITER_THRESHOLD
-                else range(N_ITER_THRESHOLD)
-            )
 
             for inactive_params_generator in _iterator:
-                if combinatorial_counts <= N_ITER_THRESHOLD:
+                if isinstance(inactive_params_generator, int):
+                    # Randomly set some parameters inactive
+                    inactive_params_sample = (
+                        subspace_continuous._sample_inactive_parameters(1)[0]
+                    )
+                else:
                     # Iterate through the combinations of all possible inactive
                     # parameters.
                     inactive_params_sample = {
@@ -209,11 +231,6 @@ def _recommend_continuous_on_subspace(
                         for sublist in inactive_params_generator
                         for param in sublist
                     }
-                else:
-                    # Randomly set some parameters inactive
-                    inactive_params_sample = (
-                        subspace_continuous._sample_inactive_parameters(1)[0]
-                    )
 
                 if len(inactive_params_sample):
                     # Turn inactive parameters to fixed features (used as input in
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 885467419..95703f137 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -130,7 +130,9 @@ def combinatorial_counts_zero_parameters(self) -> int:
             return 0
 
     @property
-    def combinatorial_zero_parameters(self) -> Iterable[tuple[str, ...]]:
+    def combinatorial_zero_parameters(
+        self
+    ) -> Iterable[tuple[tuple[str, ...], ...]] | None:
         """Return a combinatorial list of all possible zero parameters on subspace."""
         # The comments on the difference in `combinatorial_counts_zero_parameters`
         # applies here as well.
@@ -141,6 +143,8 @@ def combinatorial_zero_parameters(self) -> Iterable[tuple[str, ...]]:
                     for con in self.constraints_cardinality
                 ]
             )
+        else:
+            return None
 
     @constraints_nonlin.validator
     def _validate_constraints_nonlin(self, _, __) -> None:

From ae919d4c82a24cc0ed84a90ec5194e8ac7ea19d9 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Thu, 4 Jul 2024 10:34:27 +0200
Subject: [PATCH 007/108] Revise botorch+cardinality constraint for enhanced
 clarity

---
 baybe/recommenders/pure/bayesian/botorch.py | 123 ++++++++++++--------
 1 file changed, 72 insertions(+), 51 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index e86e9fc93..455d1c00d 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -1,7 +1,6 @@
 """Botorch recommender."""
 
 import math
-from collections.abc import Iterable
 from typing import Any, ClassVar
 
 import pandas as pd
@@ -159,18 +158,44 @@ def _recommend_continuous(
         from botorch.optim import optimize_acqf
         from torch import Tensor
 
-        def _recommend_continuous_on_subspace(
+        def _recommend_continuous_with_inactive_parameters(
             _subspace_continuous: SubspaceContinuous,
-            _fixed_parameters: dict[int, float] | None = None,
+            inactive_parameters: tuple[str, ...] | None = None,
         ) -> tuple[Tensor, Tensor]:
-            """Define a helper function on a subset of parameters."""
+            """Define a helper function that can deal with inactive parameters."""
+            if _subspace_continuous.constraints_cardinality:
+                # When there are cardinality constraints present.
+                if inactive_parameters is None:
+                    # When no parameters are constrained to zeros
+                    inactive_parameters = ()
+                    fixed_parameters = None
+                else:
+                    # When certain parameters are constrained to zeros.
+
+                    # Cast the inactive parameters to the format of fixed features used
+                    # in optimize_acqf())
+                    indices_inactive_params = [
+                        _subspace_continuous.param_names.index(key)
+                        for key in _subspace_continuous.param_names
+                        if key in inactive_parameters
+                    ]
+                    fixed_parameters = {ind: 0.0 for ind in indices_inactive_params}
+
+                # Create a new subspace by ensuring all active parameters are non-zeros
+                _subspace_continuous = _subspace_continuous._ensure_nonzero_parameters(
+                    inactive_parameters
+                )
+            else:
+                # When there is no cardinality constraint
+                fixed_parameters = None
+
             _points, _acqf_values = optimize_acqf(
                 acq_function=self._botorch_acqf,
                 bounds=torch.from_numpy(_subspace_continuous.param_bounds_comp),
                 q=batch_size,
                 num_restarts=5,  # TODO make choice for num_restarts
                 raw_samples=10,  # TODO make choice for raw_samples
-                fixed_features=_fixed_parameters,
+                fixed_features=fixed_parameters,
                 equality_constraints=[
                     c.to_botorch(_subspace_continuous.parameters)
                     for c in _subspace_continuous.constraints_lin_eq
@@ -194,73 +219,69 @@ def _recommend_continuous_on_subspace(
             # cardinality constraints.
             # * Optimize the acquisition function for different configurations and
             # pick the best one.
-            # There are two mechanisms for inactive parameter configurations. The
+            # There are two mechanisms for the inactive parameter configurations. The
             # full list of different inactive parameter configurations is used,
             # when its size is not too large; otherwise we randomly pick a
             # fixed number of inactive parameter configurations.
 
-            # Create an iterable that either iterates through range() or iterates
-            # through the full list configuration.
             if (
                 subspace_continuous.combinatorial_counts_zero_parameters
                 > N_ITER_THRESHOLD
             ):
-                _iterator: Iterable[tuple[tuple[str, ...], ...]] | range = range(
-                    N_ITER_THRESHOLD
-                )
-            elif subspace_continuous.combinatorial_zero_parameters is not None:
-                _iterator = subspace_continuous.combinatorial_zero_parameters
-            else:
-                raise RuntimeError(
-                    f"The attribute"
-                    f"{SubspaceContinuous.combinatorial_zero_parameters.__name__}"
-                    f"should not be None."
-                )
-
-            for inactive_params_generator in _iterator:
-                if isinstance(inactive_params_generator, int):
-                    # Randomly set some parameters inactive
+                # When the size of full list is too large, randomly set some
+                # parameters inactive.
+                for _ in range(N_ITER_THRESHOLD):
                     inactive_params_sample = (
                         subspace_continuous._sample_inactive_parameters(1)[0]
                     )
-                else:
-                    # Iterate through the combinations of all possible inactive
-                    # parameters.
+
+                    (
+                        points_i,
+                        acqf_values_i,
+                    ) = _recommend_continuous_with_inactive_parameters(
+                        subspace_continuous,
+                        tuple(inactive_params_sample),
+                    )
+
+                    points_all.append(points_i.unsqueeze(0))
+                    acqf_values_all.append(acqf_values_i.unsqueeze(0))
+
+            elif subspace_continuous.combinatorial_zero_parameters is not None:
+                # When the size of full list is not too large, iterate the combinations
+                # of all possible inactive parameters.
+                for (
+                    inactive_params_generator
+                ) in subspace_continuous.combinatorial_zero_parameters:
+                    # flatten inactive parameters
                     inactive_params_sample = {
                         param
                         for sublist in inactive_params_generator
                         for param in sublist
                     }
 
-                if len(inactive_params_sample):
-                    # Turn inactive parameters to fixed features (used as input in
-                    # optimize_acqf())
-                    indices_inactive_params = [
-                        subspace_continuous.param_names.index(key)
-                        for key in subspace_continuous.param_names
-                        if key in inactive_params_sample
-                    ]
-                    fixed_parameters = {ind: 0.0 for ind in indices_inactive_params}
-                else:
-                    fixed_parameters = None
-
-                # Create a new subspace
-                subspace_renewed = subspace_continuous._ensure_nonzero_parameters(
-                    inactive_params_sample
-                )
+                    (
+                        points_i,
+                        acqf_values_i,
+                    ) = _recommend_continuous_with_inactive_parameters(
+                        subspace_continuous,
+                        tuple(inactive_params_sample),
+                    )
 
-                (
-                    points_all_i,
-                    acqf_values_i,
-                ) = _recommend_continuous_on_subspace(
-                    subspace_renewed,
-                    fixed_parameters,
+                    points_all.append(points_i.unsqueeze(0))
+                    acqf_values_all.append(acqf_values_i.unsqueeze(0))
+            else:
+                raise RuntimeError(
+                    f"The attribute"
+                    f"{SubspaceContinuous.combinatorial_zero_parameters.__name__}"
+                    f"should not be None."
                 )
-                points_all.append(points_all_i.unsqueeze(0))
-                acqf_values_all.append(acqf_values_i.unsqueeze(0))
+            # Find the best option
             points = torch.cat(points_all)[torch.argmax(torch.cat(acqf_values_all)), :]
         else:
-            points, _ = _recommend_continuous_on_subspace(subspace_continuous)
+            # When there is no cardinality constraint
+            points, _ = _recommend_continuous_with_inactive_parameters(
+                subspace_continuous
+            )
 
         # Return optimized points as dataframe
         rec = pd.DataFrame(points, columns=subspace_continuous.param_names)

From 9ab8fda7427091d092661f8707cd3ffe53904689 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Thu, 11 Jul 2024 10:17:54 +0200
Subject: [PATCH 008/108] Fix property names and its docstrings

---
 baybe/constraints/continuous.py             | 20 ++++++++++----------
 baybe/recommenders/pure/bayesian/botorch.py |  8 ++++----
 baybe/searchspace/continuous.py             | 19 ++++++++++---------
 3 files changed, 24 insertions(+), 23 deletions(-)

diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py
index 6a97e9661..479d0f420 100644
--- a/baybe/constraints/continuous.py
+++ b/baybe/constraints/continuous.py
@@ -49,26 +49,26 @@ class ContinuousCardinalityConstraint(
     """Class for continuous cardinality constraints."""
 
     @property
-    def combinatorial_counts_zero_parameters(self) -> int:
-        """Return the total number of all possible combinations of zero parameters."""
-        combinatorial_counts = 0
+    def n_combinatorial_inactive_parameters(self) -> int:
+        """Counts of elements in the combinatorial list of inactive parameters."""
+        n_combinatorial_inactive_params = 0
         for i_zeros in range(
             len(self.parameters) - self.max_cardinality,
             len(self.parameters) - self.min_cardinality + 1,
         ):
-            combinatorial_counts += comb(len(self.parameters), i_zeros)
-        return combinatorial_counts
+            n_combinatorial_inactive_params += comb(len(self.parameters), i_zeros)
+        return n_combinatorial_inactive_params
 
     @property
-    def combinatorial_zero_parameters(self) -> list[tuple[str, ...]]:
-        """Return a combinatorial list of all possible zero parameters."""
-        combinatorial_zeros: list[tuple[str, ...]] = []
+    def combinatorial_inactive_parameters(self) -> list[tuple[str, ...]]:
+        """Combinatorial list of inactive parameters."""
+        combinatorial_inactive_params: list[tuple[str, ...]] = []
         for i_zeros in range(
             len(self.parameters) - self.max_cardinality,
             len(self.parameters) - self.min_cardinality + 1,
         ):
-            combinatorial_zeros.extend(combinations(self.parameters, i_zeros))
-        return combinatorial_zeros
+            combinatorial_inactive_params.extend(combinations(self.parameters, i_zeros))
+        return combinatorial_inactive_params
 
     def sample_inactive_parameters(self, batch_size: int = 1) -> list[set[str]]:
         """Sample sets of inactive parameters according to the cardinality constraints.
diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 455d1c00d..7bedde9f6 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -225,7 +225,7 @@ def _recommend_continuous_with_inactive_parameters(
             # fixed number of inactive parameter configurations.
 
             if (
-                subspace_continuous.combinatorial_counts_zero_parameters
+                subspace_continuous.n_combinatorial_inactive_parameters
                 > N_ITER_THRESHOLD
             ):
                 # When the size of full list is too large, randomly set some
@@ -246,12 +246,12 @@ def _recommend_continuous_with_inactive_parameters(
                     points_all.append(points_i.unsqueeze(0))
                     acqf_values_all.append(acqf_values_i.unsqueeze(0))
 
-            elif subspace_continuous.combinatorial_zero_parameters is not None:
+            elif subspace_continuous.combinatorial_inactive_parameters is not None:
                 # When the size of full list is not too large, iterate the combinations
                 # of all possible inactive parameters.
                 for (
                     inactive_params_generator
-                ) in subspace_continuous.combinatorial_zero_parameters:
+                ) in subspace_continuous.combinatorial_inactive_parameters:
                     # flatten inactive parameters
                     inactive_params_sample = {
                         param
@@ -272,7 +272,7 @@ def _recommend_continuous_with_inactive_parameters(
             else:
                 raise RuntimeError(
                     f"The attribute"
-                    f"{SubspaceContinuous.combinatorial_zero_parameters.__name__}"
+                    f"{SubspaceContinuous.combinatorial_inactive_parameters.__name__}"
                     f"should not be None."
                 )
             # Find the best option
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 95703f137..06837e205 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -113,16 +113,17 @@ def constraints_cardinality(self) -> tuple[ContinuousCardinalityConstraint, ...]
         )
 
     @property
-    def combinatorial_counts_zero_parameters(self) -> int:
-        """Return the total number of all possible combinations of zero parameters."""
+    def n_combinatorial_inactive_parameters(self) -> int:
+        """Counts of elements in the combinatorial list of inactive parameters."""
         # Note that both continuous subspace and continuous cardinality constraint
-        # have this property. This property is the counts for the subspace
-        # parameters; while the latter one is the counts only for that constraint.
+        # have this property. Both differs in that the former one refers to the
+        # parameters in the subspace while the latter one refers only to the
+        # constraint parameters.
         if self.constraints_cardinality:
             return reduce(
                 lambda x, y: x * y,
                 [
-                    con.combinatorial_counts_zero_parameters
+                    con.n_combinatorial_inactive_parameters
                     for con in self.constraints_cardinality
                 ],
             )
@@ -130,16 +131,16 @@ def combinatorial_counts_zero_parameters(self) -> int:
             return 0
 
     @property
-    def combinatorial_zero_parameters(
+    def combinatorial_inactive_parameters(
         self
     ) -> Iterable[tuple[tuple[str, ...], ...]] | None:
-        """Return a combinatorial list of all possible zero parameters on subspace."""
-        # The comments on the difference in `combinatorial_counts_zero_parameters`
+        """Combinatorial list of inactive parameters on subspace."""
+        # The comments on the difference in `n_combinatorial_inactive_parameters`
         # applies here as well.
         if self.constraints_cardinality:
             return product(
                 *[
-                    con.combinatorial_zero_parameters
+                    con.combinatorial_inactive_parameters
                     for con in self.constraints_cardinality
                 ]
             )

From c3831c71b2cbf9b760f7ad3ae545763435b67833 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Thu, 11 Jul 2024 10:35:24 +0200
Subject: [PATCH 009/108] Use guard clause

---
 baybe/searchspace/continuous.py | 34 ++++++++++++++++-----------------
 1 file changed, 17 insertions(+), 17 deletions(-)

diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 06837e205..c5bfda5b6 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -119,17 +119,17 @@ def n_combinatorial_inactive_parameters(self) -> int:
         # have this property. Both differs in that the former one refers to the
         # parameters in the subspace while the latter one refers only to the
         # constraint parameters.
-        if self.constraints_cardinality:
-            return reduce(
-                lambda x, y: x * y,
-                [
-                    con.n_combinatorial_inactive_parameters
-                    for con in self.constraints_cardinality
-                ],
-            )
-        else:
+        if not self.constraints_cardinality:
             return 0
 
+        return reduce(
+            lambda x, y: x * y,
+            [
+                con.n_combinatorial_inactive_parameters
+                for con in self.constraints_cardinality
+            ],
+        )
+
     @property
     def combinatorial_inactive_parameters(
         self
@@ -137,16 +137,16 @@ def combinatorial_inactive_parameters(
         """Combinatorial list of inactive parameters on subspace."""
         # The comments on the difference in `n_combinatorial_inactive_parameters`
         # applies here as well.
-        if self.constraints_cardinality:
-            return product(
-                *[
-                    con.combinatorial_inactive_parameters
-                    for con in self.constraints_cardinality
-                ]
-            )
-        else:
+        if not self.constraints_cardinality:
             return None
 
+        return product(
+            *[
+                con.combinatorial_inactive_parameters
+                for con in self.constraints_cardinality
+            ]
+        )
+
     @constraints_nonlin.validator
     def _validate_constraints_nonlin(self, _, __) -> None:
         """Validate nonlinear constraints."""

From 2f49f5a00081d602d6e9bb2622b41a540fffb17d Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Thu, 11 Jul 2024 11:47:40 +0200
Subject: [PATCH 010/108] Simplify syntax with 'prod'

---
 baybe/searchspace/continuous.py | 14 ++++++--------
 1 file changed, 6 insertions(+), 8 deletions(-)

diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index c5bfda5b6..434d071bc 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -2,9 +2,9 @@
 
 from __future__ import annotations
 
+import math
 import warnings
 from collections.abc import Collection, Iterable, Sequence
-from functools import reduce
 from itertools import chain, product
 from typing import TYPE_CHECKING, Any, cast
 
@@ -122,13 +122,11 @@ def n_combinatorial_inactive_parameters(self) -> int:
         if not self.constraints_cardinality:
             return 0
 
-        return reduce(
-            lambda x, y: x * y,
-            [
-                con.n_combinatorial_inactive_parameters
-                for con in self.constraints_cardinality
-            ],
-        )
+        n_combinatorial_inactive_params = [
+            con.n_combinatorial_inactive_parameters
+            for con in self.constraints_cardinality
+        ]
+        return math.prod(n_combinatorial_inactive_params)
 
     @property
     def combinatorial_inactive_parameters(

From d46fd60837e30da7e70eecd3db624629a38f3129 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Fri, 12 Jul 2024 09:22:24 +0200
Subject: [PATCH 011/108] Refactor botorch+cardinality constraint

---
 baybe/recommenders/pure/bayesian/botorch.py | 305 ++++++++++++--------
 baybe/searchspace/continuous.py             |   4 +-
 2 files changed, 181 insertions(+), 128 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 7bedde9f6..91a28ec1a 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -1,12 +1,15 @@
 """Botorch recommender."""
 
+from __future__ import annotations
+
 import math
-from typing import Any, ClassVar
+from typing import TYPE_CHECKING, Any, ClassVar
 
 import pandas as pd
 from attr.converters import optional
 from attrs import define, field
 
+from baybe.constraints import ContinuousCardinalityConstraint
 from baybe.exceptions import NoMCAcquisitionFunctionError
 from baybe.recommenders.pure.bayesian.base import BayesianRecommender
 from baybe.searchspace import (
@@ -21,7 +24,14 @@
     sample_numerical_df,
 )
 
-N_ITER_THRESHOLD = 10
+if TYPE_CHECKING:
+    from torch import Tensor
+
+N_THRESHOLD_INACTIVE_PARAMETERS_GENERATOR: int = 10
+"""This threshold controls which inactive parameters generator is chosen. There are
+two mechanisms:
+* Iterating the combinatorial list of all possible inactive parameters,
+* Iterate a fixed number of randomly generated inactive parameter configurations."""
 
 
 @define(kw_only=True)
@@ -142,7 +152,6 @@ def _recommend_continuous(
         Raises:
             NoMCAcquisitionFunctionError: If a non-Monte Carlo acquisition function is
                 used with a batch size > 1.
-            RuntimeError: If the combinatorial list of inactive parameters is None.
 
         Returns:
             A dataframe containing the recommendations as individual rows.
@@ -154,138 +163,184 @@ def _recommend_continuous(
                 f"acquisition functions for batch sizes > 1."
             )
 
+        if len(subspace_continuous.constraints_cardinality):
+            points, _ = self._recommend_continuous_with_cardinality_constraints(
+                subspace_continuous,
+                batch_size,
+            )
+        else:
+            points, _ = self._recommend_continuous_without_cardinality_constraints(
+                subspace_continuous,
+                batch_size,
+            )
+
+        # Return optimized points as dataframe
+        rec = pd.DataFrame(points, columns=subspace_continuous.param_names)
+        return rec
+
+    def _recommend_continuous_with_cardinality_constraints(
+        self,
+        subspace_continuous: SubspaceContinuous,
+        batch_size: int,
+    ) -> tuple[Tensor, Tensor]:
+        """Recommend from a continuous search space with cardinality constraints.
+
+        Args:
+            subspace_continuous: The continuous subspace from which to generate
+                recommendations.
+            batch_size: The size of the recommendation batch.
+
+        Returns:
+            The recommendations.
+            The acquisition values.
+
+        Raises:
+            RuntimeError: If the continuous search space has no cardinality constraint.
+        """
         import torch
-        from botorch.optim import optimize_acqf
-        from torch import Tensor
-
-        def _recommend_continuous_with_inactive_parameters(
-            _subspace_continuous: SubspaceContinuous,
-            inactive_parameters: tuple[str, ...] | None = None,
-        ) -> tuple[Tensor, Tensor]:
-            """Define a helper function that can deal with inactive parameters."""
-            if _subspace_continuous.constraints_cardinality:
-                # When there are cardinality constraints present.
-                if inactive_parameters is None:
-                    # When no parameters are constrained to zeros
-                    inactive_parameters = ()
-                    fixed_parameters = None
-                else:
-                    # When certain parameters are constrained to zeros.
-
-                    # Cast the inactive parameters to the format of fixed features used
-                    # in optimize_acqf())
-                    indices_inactive_params = [
-                        _subspace_continuous.param_names.index(key)
-                        for key in _subspace_continuous.param_names
-                        if key in inactive_parameters
-                    ]
-                    fixed_parameters = {ind: 0.0 for ind in indices_inactive_params}
-
-                # Create a new subspace by ensuring all active parameters are non-zeros
-                _subspace_continuous = _subspace_continuous._ensure_nonzero_parameters(
-                    inactive_parameters
-                )
-            else:
-                # When there is no cardinality constraint
-                fixed_parameters = None
-
-            _points, _acqf_values = optimize_acqf(
-                acq_function=self._botorch_acqf,
-                bounds=torch.from_numpy(_subspace_continuous.param_bounds_comp),
-                q=batch_size,
-                num_restarts=5,  # TODO make choice for num_restarts
-                raw_samples=10,  # TODO make choice for raw_samples
-                fixed_features=fixed_parameters,
-                equality_constraints=[
-                    c.to_botorch(_subspace_continuous.parameters)
-                    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)
-                    for c in _subspace_continuous.constraints_lin_ineq
-                ]
-                or None,  # TODO: https://github.com/pytorch/botorch/issues/2042
-                sequential=self.sequential_continuous,
+
+        if not subspace_continuous.constraints_cardinality:
+            raise RuntimeError(
+                f"This method expects a subspace object with constraints of type "
+                f"{ContinuousCardinalityConstraint.__name__}. For a subspace object "
+                f"without constraints of type"
+                f" {ContinuousCardinalityConstraint.__name__}, "
+                f"{self._recommend_continuous_without_cardinality_constraints.__name__}."  # noqa
             )
-            return _points, _acqf_values
 
-        if len(subspace_continuous.constraints_cardinality):
-            acqf_values_all: list[Tensor] = []
-            points_all: list[Tensor] = []
-
-            # The key steps of handling cardinality constraint are
-            # * Determine several configurations of inactive parameters based on the
-            # cardinality constraints.
-            # * Optimize the acquisition function for different configurations and
-            # pick the best one.
-            # There are two mechanisms for the inactive parameter configurations. The
-            # full list of different inactive parameter configurations is used,
-            # when its size is not too large; otherwise we randomly pick a
-            # fixed number of inactive parameter configurations.
-
-            if (
-                subspace_continuous.n_combinatorial_inactive_parameters
-                > N_ITER_THRESHOLD
-            ):
-                # When the size of full list is too large, randomly set some
-                # parameters inactive.
-                for _ in range(N_ITER_THRESHOLD):
-                    inactive_params_sample = (
-                        subspace_continuous._sample_inactive_parameters(1)[0]
-                    )
-
-                    (
-                        points_i,
-                        acqf_values_i,
-                    ) = _recommend_continuous_with_inactive_parameters(
-                        subspace_continuous,
-                        tuple(inactive_params_sample),
-                    )
-
-                    points_all.append(points_i.unsqueeze(0))
-                    acqf_values_all.append(acqf_values_i.unsqueeze(0))
-
-            elif subspace_continuous.combinatorial_inactive_parameters is not None:
-                # When the size of full list is not too large, iterate the combinations
-                # of all possible inactive parameters.
-                for (
-                    inactive_params_generator
-                ) in subspace_continuous.combinatorial_inactive_parameters:
-                    # flatten inactive parameters
-                    inactive_params_sample = {
+        acqf_values_all: list[Tensor] = []
+        points_all: list[Tensor] = []
+
+        def append_recommendation_for_inactive_parameters_setting(
+            inactive_parameters: tuple[str, ...],
+        ):
+            """Append the recommendation for each inactive parameter configuration.
+
+            Args:
+                inactive_parameters: A list of inactive parameters.
+            """
+            # Create a new subspace by ensuring all active parameters being
+            # non-zeros.
+            subspace_continuous_with_active_params = (
+                subspace_continuous._ensure_nonzero_parameters(inactive_parameters)
+            )
+            # Optimize the acquisition function
+            (
+                points_i,
+                acqf_values_i,
+            ) = self._recommend_continuous_without_cardinality_constraints(
+                subspace_continuous_with_active_params,
+                batch_size,
+                inactive_parameters,
+            )
+            # Append recommendation list and acquisition function values
+            points_all.append(points_i.unsqueeze(0))
+            acqf_values_all.append(acqf_values_i.unsqueeze(0))
+
+        # Below we start recommendation
+        if (
+            subspace_continuous.n_combinatorial_inactive_parameters
+            > N_THRESHOLD_INACTIVE_PARAMETERS_GENERATOR
+        ):
+            # When the combinatorial list is too large, randomly set some parameters
+            # inactive.
+            for _ in range(N_THRESHOLD_INACTIVE_PARAMETERS_GENERATOR):
+                inactive_params_sample = tuple(
+                    subspace_continuous._sample_inactive_parameters(1)[0]
+                )
+                append_recommendation_for_inactive_parameters_setting(
+                    inactive_params_sample
+                )
+        else:
+            # When the combinatorial list is not too large, iterate the combinatorial
+            # list of all possible inactive parameters.
+            for (
+                inactive_params_generator
+            ) in subspace_continuous.combinatorial_inactive_parameters:
+                # Flatten inactive parameter generator
+                inactive_params_sample = tuple(
+                    {
                         param
                         for sublist in inactive_params_generator
                         for param in sublist
                     }
-
-                    (
-                        points_i,
-                        acqf_values_i,
-                    ) = _recommend_continuous_with_inactive_parameters(
-                        subspace_continuous,
-                        tuple(inactive_params_sample),
-                    )
-
-                    points_all.append(points_i.unsqueeze(0))
-                    acqf_values_all.append(acqf_values_i.unsqueeze(0))
-            else:
-                raise RuntimeError(
-                    f"The attribute"
-                    f"{SubspaceContinuous.combinatorial_inactive_parameters.__name__}"
-                    f"should not be None."
                 )
-            # Find the best option
-            points = torch.cat(points_all)[torch.argmax(torch.cat(acqf_values_all)), :]
-        else:
-            # When there is no cardinality constraint
-            points, _ = _recommend_continuous_with_inactive_parameters(
-                subspace_continuous
+                append_recommendation_for_inactive_parameters_setting(
+                    inactive_params_sample
+                )
+
+        # Find the best option
+        points = torch.cat(points_all)[torch.argmax(torch.cat(acqf_values_all)), :]
+        acqf_values = torch.max(torch.cat(acqf_values_all))
+        return points, acqf_values
+
+    def _recommend_continuous_without_cardinality_constraints(
+        self,
+        subspace_continuous: SubspaceContinuous,
+        batch_size: int,
+        inactive_parameters: tuple[str, ...] | None = None,
+    ) -> tuple[Tensor, Tensor]:
+        """Recommend from a continuous search space without cardinality constraints.
+
+        Args:
+            subspace_continuous: The continuous subspace from which to generate
+                recommendations.
+            batch_size: The size of the recommendation batch.
+            inactive_parameters: A list of inactive parameters.
+
+        Returns:
+            The recommendations.
+            The acquisition values.
+
+        Raises:
+            RuntimeError: If the continuous search space has any cardinality
+                constraints.
+        """
+        import torch
+        from botorch.optim import optimize_acqf
+
+        if subspace_continuous.constraints_cardinality:
+            raise RuntimeError(
+                f"This method expects only subspace object without constraints of type "
+                f"{ContinuousCardinalityConstraint.__name__}. For a subspace object "
+                f"with constraints of type {ContinuousCardinalityConstraint.__name__}, "
+                f"try method {self._recommend_continuous.__name__}."
             )
 
-        # Return optimized points as dataframe
-        rec = pd.DataFrame(points, columns=subspace_continuous.param_names)
-        return rec
+        if not inactive_parameters:
+            fixed_parameters = None
+        else:
+            # Cast the inactive parameters to the format of fixed features used
+            # in optimize_acqf())
+            indices_inactive_params = [
+                subspace_continuous.param_names.index(key)
+                for key in subspace_continuous.param_names
+                if key in inactive_parameters
+            ]
+            fixed_parameters = {ind: 0.0 for ind in indices_inactive_params}
+
+        points, acqf_values = optimize_acqf(
+            acq_function=self._botorch_acqf,
+            bounds=torch.from_numpy(subspace_continuous.param_bounds_comp),
+            q=batch_size,
+            num_restarts=5,  # TODO make choice for num_restarts
+            raw_samples=10,  # TODO make choice for raw_samples
+            fixed_features=fixed_parameters,
+            equality_constraints=[
+                c.to_botorch(subspace_continuous.parameters)
+                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)
+                for c in subspace_continuous.constraints_lin_ineq
+            ]
+            or None,
+            # TODO: https://github.com/pytorch/botorch/issues/2042
+            sequential=self.sequential_continuous,
+        )
+        return points, acqf_values
 
     def _recommend_hybrid(
         self,
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 434d071bc..4a65ea816 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -131,12 +131,10 @@ def n_combinatorial_inactive_parameters(self) -> int:
     @property
     def combinatorial_inactive_parameters(
         self
-    ) -> Iterable[tuple[tuple[str, ...], ...]] | None:
+    ) -> Iterable[tuple[tuple[str, ...], ...]]:
         """Combinatorial list of inactive parameters on subspace."""
         # The comments on the difference in `n_combinatorial_inactive_parameters`
         # applies here as well.
-        if not self.constraints_cardinality:
-            return None
 
         return product(
             *[

From 293e2efd2c9a70e9a0569909b60e4c682a119351 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Fri, 12 Jul 2024 11:58:42 +0200
Subject: [PATCH 012/108] Make 'n_threshold_inactive_parameters_generator' an
 attribute of botorch recommender

---
 baybe/recommenders/pure/bayesian/botorch.py | 21 +++++++++++++--------
 1 file changed, 13 insertions(+), 8 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 91a28ec1a..d749343f6 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -8,6 +8,7 @@
 import pandas as pd
 from attr.converters import optional
 from attrs import define, field
+from attrs.validators import ge, instance_of
 
 from baybe.constraints import ContinuousCardinalityConstraint
 from baybe.exceptions import NoMCAcquisitionFunctionError
@@ -27,12 +28,6 @@
 if TYPE_CHECKING:
     from torch import Tensor
 
-N_THRESHOLD_INACTIVE_PARAMETERS_GENERATOR: int = 10
-"""This threshold controls which inactive parameters generator is chosen. There are
-two mechanisms:
-* Iterating the combinatorial list of all possible inactive parameters,
-* Iterate a fixed number of randomly generated inactive parameter configurations."""
-
 
 @define(kw_only=True)
 class BotorchRecommender(BayesianRecommender):
@@ -70,6 +65,16 @@ class BotorchRecommender(BayesianRecommender):
     """Percentage of discrete search space that is sampled when performing hybrid search
     space optimization. Ignored when ``hybrid_sampler="None"``."""
 
+    n_threshold_inactive_parameters_generator: int = field(
+        default=10, validator=[instance_of(int), ge(1)]
+    )
+    """Threshold used for checking which inactive parameters generator is used when
+    cardinality constraints are present. When the size of the combinatorial list of
+    all possible inactive parameters is larger than the threshold, a fixed number of
+    randomly generated inactive parameter configurations are used and the best
+    optimum among them is recommended; Otherwise, we find the best one by iterating the
+    combinatorial list of all possible inactive parameters """
+
     @sampling_percentage.validator
     def _validate_percentage(  # noqa: DOC101, DOC103
         self, _: Any, value: float
@@ -240,11 +245,11 @@ def append_recommendation_for_inactive_parameters_setting(
         # Below we start recommendation
         if (
             subspace_continuous.n_combinatorial_inactive_parameters
-            > N_THRESHOLD_INACTIVE_PARAMETERS_GENERATOR
+            > self.n_threshold_inactive_parameters_generator
         ):
             # When the combinatorial list is too large, randomly set some parameters
             # inactive.
-            for _ in range(N_THRESHOLD_INACTIVE_PARAMETERS_GENERATOR):
+            for _ in range(self.n_threshold_inactive_parameters_generator):
                 inactive_params_sample = tuple(
                     subspace_continuous._sample_inactive_parameters(1)[0]
                 )

From f8d0713c0986dc7d021455ef6692a4a14cd082f3 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Thu, 15 Aug 2024 09:53:48 +0200
Subject: [PATCH 013/108] Refactor combinatorial properties of cardinality
 constraint

---
 baybe/constraints/continuous.py | 33 ++++++++++++++++-----------------
 baybe/searchspace/continuous.py |  6 +++---
 2 files changed, 19 insertions(+), 20 deletions(-)

diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py
index 479d0f420..c50193910 100644
--- a/baybe/constraints/continuous.py
+++ b/baybe/constraints/continuous.py
@@ -1,6 +1,7 @@
 """Continuous constraints."""
 
 import math
+from collections.abc import Iterator
 from itertools import combinations
 from math import comb
 
@@ -49,26 +50,24 @@ class ContinuousCardinalityConstraint(
     """Class for continuous cardinality constraints."""
 
     @property
-    def n_combinatorial_inactive_parameters(self) -> int:
-        """Counts of elements in the combinatorial list of inactive parameters."""
-        n_combinatorial_inactive_params = 0
-        for i_zeros in range(
-            len(self.parameters) - self.max_cardinality,
-            len(self.parameters) - self.min_cardinality + 1,
-        ):
-            n_combinatorial_inactive_params += comb(len(self.parameters), i_zeros)
-        return n_combinatorial_inactive_params
+    def n_inactive_parameter_combinations(self) -> int:
+        """The number of possible inactive parameter combinations."""
+        return sum(
+            comb(len(self.parameters), n_inactive_parameters)
+            for n_inactive_parameters in self._inactive_set_sizes()
+        )
 
-    @property
-    def combinatorial_inactive_parameters(self) -> list[tuple[str, ...]]:
-        """Combinatorial list of inactive parameters."""
-        combinatorial_inactive_params: list[tuple[str, ...]] = []
-        for i_zeros in range(
+    def _inactive_set_sizes(self) -> Iterator[int]:
+        """Iterate over all possible sizes of inactive parameter sets."""
+        return range(
             len(self.parameters) - self.max_cardinality,
             len(self.parameters) - self.min_cardinality + 1,
-        ):
-            combinatorial_inactive_params.extend(combinations(self.parameters, i_zeros))
-        return combinatorial_inactive_params
+        )
+
+    def inactive_parameter_combinations(self) -> Iterator[frozenset[str]]:
+        """Iterate over all possible combinations of inactive parameters."""
+        for n_inactive_parameters in self._inactive_set_sizes():
+            yield from combinations(self.parameters, n_inactive_parameters)
 
     def sample_inactive_parameters(self, batch_size: int = 1) -> list[set[str]]:
         """Sample sets of inactive parameters according to the cardinality constraints.
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 4a65ea816..abdfd25d0 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -123,14 +123,14 @@ def n_combinatorial_inactive_parameters(self) -> int:
             return 0
 
         n_combinatorial_inactive_params = [
-            con.n_combinatorial_inactive_parameters
+            con.n_inactive_parameter_combinations
             for con in self.constraints_cardinality
         ]
         return math.prod(n_combinatorial_inactive_params)
 
     @property
     def combinatorial_inactive_parameters(
-        self
+        self,
     ) -> Iterable[tuple[tuple[str, ...], ...]]:
         """Combinatorial list of inactive parameters on subspace."""
         # The comments on the difference in `n_combinatorial_inactive_parameters`
@@ -138,7 +138,7 @@ def combinatorial_inactive_parameters(
 
         return product(
             *[
-                con.combinatorial_inactive_parameters
+                con.inactive_parameter_combinations()
                 for con in self.constraints_cardinality
             ]
         )

From 76b5d727c3791d21e6c00c36f8aa0cdbdd824af5 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Thu, 15 Aug 2024 10:18:21 +0200
Subject: [PATCH 014/108] Refactor combinatorial properties of continuous
 subspace

---
 baybe/recommenders/pure/bayesian/botorch.py | 14 ++-------
 baybe/searchspace/continuous.py             | 34 ++++++---------------
 2 files changed, 13 insertions(+), 35 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index d749343f6..30f0525d9 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -244,7 +244,7 @@ def append_recommendation_for_inactive_parameters_setting(
 
         # Below we start recommendation
         if (
-            subspace_continuous.n_combinatorial_inactive_parameters
+            subspace_continuous.n_inactive_parameter_combinations
             > self.n_threshold_inactive_parameters_generator
         ):
             # When the combinatorial list is too large, randomly set some parameters
@@ -261,17 +261,9 @@ def append_recommendation_for_inactive_parameters_setting(
             # list of all possible inactive parameters.
             for (
                 inactive_params_generator
-            ) in subspace_continuous.combinatorial_inactive_parameters:
-                # Flatten inactive parameter generator
-                inactive_params_sample = tuple(
-                    {
-                        param
-                        for sublist in inactive_params_generator
-                        for param in sublist
-                    }
-                )
+            ) in subspace_continuous.inactive_parameter_combinations():
                 append_recommendation_for_inactive_parameters_setting(
-                    inactive_params_sample
+                    inactive_params_generator
                 )
 
         # Find the best option
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index abdfd25d0..a3e6e6099 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -113,35 +113,21 @@ def constraints_cardinality(self) -> tuple[ContinuousCardinalityConstraint, ...]
         )
 
     @property
-    def n_combinatorial_inactive_parameters(self) -> int:
-        """Counts of elements in the combinatorial list of inactive parameters."""
-        # Note that both continuous subspace and continuous cardinality constraint
-        # have this property. Both differs in that the former one refers to the
-        # parameters in the subspace while the latter one refers only to the
-        # constraint parameters.
-        if not self.constraints_cardinality:
-            return 0
-
-        n_combinatorial_inactive_params = [
-            con.n_inactive_parameter_combinations
-            for con in self.constraints_cardinality
-        ]
-        return math.prod(n_combinatorial_inactive_params)
-
-    @property
-    def combinatorial_inactive_parameters(
-        self,
-    ) -> Iterable[tuple[tuple[str, ...], ...]]:
-        """Combinatorial list of inactive parameters on subspace."""
-        # The comments on the difference in `n_combinatorial_inactive_parameters`
-        # applies here as well.
+    def n_inactive_parameter_combinations(self) -> int:
+        """The number of possible inactive parameter combinations."""
+        return math.prod(
+            c.n_inactive_parameter_combinations for c in self.constraints_cardinality
+        )
 
-        return product(
+    def inactive_parameter_combinations(self) -> Iterable[frozenset[str]]:
+        """Iterate over all possible combinations of inactive parameters."""
+        for combination in product(
             *[
                 con.inactive_parameter_combinations()
                 for con in self.constraints_cardinality
             ]
-        )
+        ):
+            yield frozenset(chain(*combination))
 
     @constraints_nonlin.validator
     def _validate_constraints_nonlin(self, _, __) -> None:

From 5c92079b98245b1f1420fae0c85da9a1474c7c41 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Thu, 15 Aug 2024 11:34:03 +0200
Subject: [PATCH 015/108] Refactor constraint validation

---
 baybe/constraints/validation.py | 63 +++++++++++++++++++--------------
 baybe/searchspace/continuous.py |  4 +--
 2 files changed, 38 insertions(+), 29 deletions(-)

diff --git a/baybe/constraints/validation.py b/baybe/constraints/validation.py
index 61512fac6..17a2489a9 100644
--- a/baybe/constraints/validation.py
+++ b/baybe/constraints/validation.py
@@ -1,6 +1,6 @@
 """Validation functionality for constraints."""
 
-from collections.abc import Collection, Sequence
+from collections.abc import Collection
 from itertools import combinations
 
 from baybe.constraints.base import Constraint
@@ -11,6 +11,11 @@
 from baybe.parameters import NumericalContinuousParameter
 from baybe.parameters.base import Parameter
 
+try:  # For python < 3.11, use the exceptiongroup backport
+    ExceptionGroup
+except NameError:
+    from exceptiongroup import ExceptionGroup
+
 
 def validate_constraints(  # noqa: DOC101, DOC103
     constraints: Collection[Constraint], parameters: Collection[Parameter]
@@ -27,8 +32,8 @@ def validate_constraints(  # noqa: DOC101, DOC103
         ValueError: If any discrete constraint includes a continuous parameter.
         ValueError: If any discrete constraint that is valid only for numerical
             discrete parameters includes non-numerical discrete parameters.
-        ValueError: If the bounds of any parameter in a cardinality constraint does
-            not cover zero.
+        ValueError: If any parameter affected by a cardinality constraint does
+            not include zero.
     """
     if sum(isinstance(itm, DiscreteDependenciesConstraint) for itm in constraints) > 1:
         raise ValueError(
@@ -85,8 +90,8 @@ def validate_constraints(  # noqa: DOC101, DOC103
             )
 
         if isinstance(constraint, ContinuousCardinalityConstraint):
-            validate_parameters_bounds_in_cardinality_constraint(
-                params_continuous, constraint
+            validate_cardinality_constraint_parameter_bounds(
+                constraint, params_continuous
             )
 
 
@@ -111,33 +116,37 @@ def validate_cardinality_constraints_are_nonoverlapping(
             )
 
 
-def validate_parameters_bounds_in_cardinality_constraint(
-    parameters: Sequence[NumericalContinuousParameter],
+def validate_cardinality_constraint_parameter_bounds(
     constraint: ContinuousCardinalityConstraint,
+    parameters: Collection[NumericalContinuousParameter],
 ) -> None:
-    """Validate that the bounds of all parameters in a cardinality constraint cover
-    zero.
+    """Validate that all parameters of a continuous cardinality constraint include zero.
 
     Args:
-        parameters: A collection of continuous numerical parameters.
         constraint: A continuous cardinality constraint.
+        parameters: A collection of parameters, including those affected by the
+            constraint.
 
     Raises:
-        ValueError: If the bounds of any parameter of a constraint does not cover zero.
-    """  # noqa D205
-    param_names = [p.name for p in parameters]
-    for param_in_constraint in constraint.parameters:
-        # Note that this implementation checks implicitly that all constraint
-        # parameters must be included in the list of parameters. Otherwise Runtime
-        # error occurs.
-        if (
-            param := parameters[param_names.index(param_in_constraint)]
-        ) and not param.is_in_range(0.0):
-            raise ValueError(
-                f"The bounds of all parameters in a constraint of type "
-                f"`{ContinuousCardinalityConstraint.__name__}` must cover "
-                f"zero. Either correct the parameter ({param}) bounds:"
-                f" {param.bounds=} or remove the parameter {param} from the "
-                f"{constraint=} and update the minimum/maximum cardinality "
-                f"accordingly."
+        ValueError: If one of the affected parameters does not include zero.
+        ExceptionGroup: If several of the affected parameters do not include zero.
+    """
+    exceptions = []
+    for name in constraint.parameters:
+        # We implicitly assume that the corresponding parameter exists
+        parameter = next(p for p in parameters if p.name == name)
+
+        if not parameter.is_in_range(0.0):
+            exceptions.append(
+                ValueError(
+                    f"The bounds of all parameters affected by a constraint of type "
+                    f"'{ContinuousCardinalityConstraint.__name__}' must include zero, "
+                    f"but the bounds of parameter '{name}' are: "
+                    f"{parameter.bounds.to_tuple()}"
+                )
             )
+
+    if exceptions:
+        if len(exceptions) == 1:
+            raise exceptions[0]
+        raise ExceptionGroup("invalid parameter bounds", exceptions)
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index a3e6e6099..f766ea2f5 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -19,8 +19,8 @@
 )
 from baybe.constraints.base import ContinuousConstraint, ContinuousNonlinearConstraint
 from baybe.constraints.validation import (
+    validate_cardinality_constraint_parameter_bounds,
     validate_cardinality_constraints_are_nonoverlapping,
-    validate_parameters_bounds_in_cardinality_constraint,
 )
 from baybe.parameters import NumericalContinuousParameter
 from baybe.parameters.base import ContinuousParameter
@@ -138,7 +138,7 @@ def _validate_constraints_nonlin(self, _, __) -> None:
         )
 
         for con in self.constraints_cardinality:
-            validate_parameters_bounds_in_cardinality_constraint(self.parameters, con)
+            validate_cardinality_constraint_parameter_bounds(con, self.parameters)
 
     def to_searchspace(self) -> SearchSpace:
         """Turn the subspace into a search space with no discrete part."""

From f07a452e59e9932c5e269e1d0b40e26ffab72f95 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Thu, 15 Aug 2024 13:24:11 +0200
Subject: [PATCH 016/108] Move factory code up

---
 baybe/searchspace/continuous.py | 110 ++++++++++++++++----------------
 1 file changed, 55 insertions(+), 55 deletions(-)

diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index f766ea2f5..696b28077 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -304,6 +304,61 @@ def _drop_parameters(self, parameter_names: Collection[str]) -> SubspaceContinuo
             ],
         )
 
+    def _ensure_nonzero_parameters(
+        self,
+        inactive_parameters: Collection[str],
+        zero_threshold: float = ZERO_THRESHOLD,
+    ) -> SubspaceContinuous:
+        """Create a new subspace with following several actions.
+
+        * Ensure active parameter != 0.0.
+        * Remove cardinality constraint.
+
+        Args:
+            inactive_parameters: A list of inactive parameters.
+            zero_threshold: Threshold for checking whether a value is zero.
+
+        Returns:
+            A new subspace object.
+        """
+        # Active parameters: parameters involved in cardinality constraints
+        active_params_sample = set(
+            self.param_names_in_cardinality_constraint
+        ).difference(set(inactive_parameters))
+
+        constraints_lin_ineq = list(self.constraints_lin_ineq)
+        for active_param in active_params_sample:
+            index = self.param_names.index(active_param)
+
+            # TODO: Ensure x != 0 when x in [..., 0, ...] is not done. Do we need it?
+            # TODO: To ensure the minimum cardinality constraints, shall we keep the x
+            #  != 0 operations or shall we instead skip the invalid results at the end
+            # Ensure x != 0 when bounds = [..., 0]. This is needed, otherwise
+            # the minimum cardinality constraint is easily violated
+            if self.parameters[index].bounds.upper == 0:
+                constraints_lin_ineq.append(
+                    ContinuousLinearInequalityConstraint(
+                        parameters=[active_param],
+                        coefficients=[-1.0],
+                        rhs=min(zero_threshold, -self.parameters[index].bounds.lower),
+                    )
+                )
+            # Ensure x != 0 when bounds = [0, ...]
+            elif self.parameters[index].bounds.lower == 0:
+                constraints_lin_ineq.append(
+                    ContinuousLinearInequalityConstraint(
+                        parameters=[active_param],
+                        coefficients=[1.0],
+                        rhs=min(zero_threshold, self.parameters[index].bounds.upper),
+                    ),
+                )
+
+        return SubspaceContinuous(
+            parameters=tuple(self.parameters),
+            constraints_lin_eq=self.constraints_lin_eq,
+            constraints_lin_ineq=tuple(constraints_lin_ineq),
+        )
+
     def transform(
         self,
         df: pd.DataFrame | None = None,
@@ -485,61 +540,6 @@ def _sample_inactive_parameters(self, batch_size: int = 1) -> list[set[str]]:
         ]
         return [set(chain(*x)) for x in zip(*inactives_per_constraint)]
 
-    def _ensure_nonzero_parameters(
-        self,
-        inactive_parameters: Collection[str],
-        zero_threshold: float = ZERO_THRESHOLD,
-    ) -> SubspaceContinuous:
-        """Create a new subspace with following several actions.
-
-        * Ensure active parameter != 0.0.
-        * Remove cardinality constraint.
-
-        Args:
-            inactive_parameters: A list of inactive parameters.
-            zero_threshold: Threshold for checking whether a value is zero.
-
-        Returns:
-            A new subspace object.
-        """
-        # Active parameters: parameters involved in cardinality constraints
-        active_params_sample = set(
-            self.param_names_in_cardinality_constraint
-        ).difference(set(inactive_parameters))
-
-        constraints_lin_ineq = list(self.constraints_lin_ineq)
-        for active_param in active_params_sample:
-            index = self.param_names.index(active_param)
-
-            # TODO: Ensure x != 0 when x in [..., 0, ...] is not done. Do we need it?
-            # TODO: To ensure the minimum cardinality constraints, shall we keep the x
-            #  != 0 operations or shall we instead skip the invalid results at the end
-            # Ensure x != 0 when bounds = [..., 0]. This is needed, otherwise
-            # the minimum cardinality constraint is easily violated
-            if self.parameters[index].bounds.upper == 0:
-                constraints_lin_ineq.append(
-                    ContinuousLinearInequalityConstraint(
-                        parameters=[active_param],
-                        coefficients=[-1.0],
-                        rhs=min(zero_threshold, -self.parameters[index].bounds.lower),
-                    )
-                )
-            # Ensure x != 0 when bounds = [0, ...]
-            elif self.parameters[index].bounds.lower == 0:
-                constraints_lin_ineq.append(
-                    ContinuousLinearInequalityConstraint(
-                        parameters=[active_param],
-                        coefficients=[1.0],
-                        rhs=min(zero_threshold, self.parameters[index].bounds.upper),
-                    ),
-                )
-
-        return SubspaceContinuous(
-            parameters=tuple(self.parameters),
-            constraints_lin_eq=self.constraints_lin_eq,
-            constraints_lin_ineq=tuple(constraints_lin_ineq),
-        )
-
     def samples_full_factorial(self, n_points: int = 1) -> pd.DataFrame:
         """Deprecated!"""  # noqa: D401
         warnings.warn(

From c5b014df7af969ddc3e35bca0159a6488ef081bd Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Thu, 15 Aug 2024 13:50:48 +0200
Subject: [PATCH 017/108] Simplify constructor code

---
 baybe/searchspace/continuous.py | 38 +++++++++++++++++++++------------
 1 file changed, 24 insertions(+), 14 deletions(-)

diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 696b28077..049755b32 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -38,7 +38,6 @@
     from baybe.searchspace.core import SearchSpace
 
 _MAX_CARDINALITY_SAMPLING_ATTEMPTS = 10_000
-ZERO_THRESHOLD = 1e-5
 
 
 @define
@@ -273,6 +272,7 @@ def param_names(self) -> tuple[str, ...]:
     @property
     def param_names_in_cardinality_constraint(self) -> tuple[str, ...]:
         """Return list of parameter names involved in cardinality constraints."""
+        # TODO: Is this property really needed? If so, apply naming conventions.
         params_per_cardinatliy_constraint = [
             c.parameters for c in self.constraints_cardinality
         ]
@@ -307,7 +307,7 @@ def _drop_parameters(self, parameter_names: Collection[str]) -> SubspaceContinuo
     def _ensure_nonzero_parameters(
         self,
         inactive_parameters: Collection[str],
-        zero_threshold: float = ZERO_THRESHOLD,
+        inactivity_threshold: float = 1e-5,
     ) -> SubspaceContinuous:
         """Create a new subspace with following several actions.
 
@@ -316,47 +316,57 @@ def _ensure_nonzero_parameters(
 
         Args:
             inactive_parameters: A list of inactive parameters.
-            zero_threshold: Threshold for checking whether a value is zero.
+            inactivity_threshold: Threshold for checking whether a value is zero.
 
         Returns:
             A new subspace object.
         """
+        # TODO: Revise function name/docstring and arguments. In particular: why
+        #   does the function expect the inactive parameters instead of the active ones?
+
+        # TODO: Shouldn't the x != 0 constraints be applied on the level of the
+        #   individual constrains, also taking into account whether min_cardinality > 0?
+
+        # TODO: Instead of adding additional constraints, why not alter the parameter
+        #   bounds? In case we keep the constraints: is the sign of the threshold
+        #   correct?
+
         # Active parameters: parameters involved in cardinality constraints
-        active_params_sample = set(
+        active_parameter_names = set(
             self.param_names_in_cardinality_constraint
         ).difference(set(inactive_parameters))
 
         constraints_lin_ineq = list(self.constraints_lin_ineq)
-        for active_param in active_params_sample:
-            index = self.param_names.index(active_param)
+        for name in active_parameter_names:
+            parameter = next(p for p in self.parameters if p.name == name)
 
             # TODO: Ensure x != 0 when x in [..., 0, ...] is not done. Do we need it?
             # TODO: To ensure the minimum cardinality constraints, shall we keep the x
             #  != 0 operations or shall we instead skip the invalid results at the end
             # Ensure x != 0 when bounds = [..., 0]. This is needed, otherwise
             # the minimum cardinality constraint is easily violated
-            if self.parameters[index].bounds.upper == 0:
+            if parameter.bounds.upper == 0:
                 constraints_lin_ineq.append(
                     ContinuousLinearInequalityConstraint(
-                        parameters=[active_param],
+                        parameters=[name],
                         coefficients=[-1.0],
-                        rhs=min(zero_threshold, -self.parameters[index].bounds.lower),
+                        rhs=min(inactivity_threshold, -parameter.bounds.lower),
                     )
                 )
             # Ensure x != 0 when bounds = [0, ...]
-            elif self.parameters[index].bounds.lower == 0:
+            elif parameter.bounds.lower == 0:
                 constraints_lin_ineq.append(
                     ContinuousLinearInequalityConstraint(
-                        parameters=[active_param],
+                        parameters=[name],
                         coefficients=[1.0],
-                        rhs=min(zero_threshold, self.parameters[index].bounds.upper),
+                        rhs=min(inactivity_threshold, parameter.bounds.upper),
                     ),
                 )
 
         return SubspaceContinuous(
-            parameters=tuple(self.parameters),
+            parameters=self.parameters,
             constraints_lin_eq=self.constraints_lin_eq,
-            constraints_lin_ineq=tuple(constraints_lin_ineq),
+            constraints_lin_ineq=constraints_lin_ineq,
         )
 
     def transform(

From 306c9d2d7674825acb94aa5b6f2c4d08b20f0c62 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Thu, 15 Aug 2024 14:36:05 +0200
Subject: [PATCH 018/108] Update CHANGELOG.md

---
 CHANGELOG.md | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f85c11b08..4de24b099 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## [Unreleased]
 ### Added
 - `py.typed` file to enable the use of type checkers on the user side
+- `ContinuousCardinalityConstraint` is now compatible with `BotorchRecommender`
+- Utilities `inactive_parameter_combinations` and`n_inactive_parameter_combinations` 
+  in both `ContinuousCardinalityConstraint`and `SubspaceContinuous`
+- Attribute `n_threshold_inactive_parameters_generator` added to `BotorchRecommender`
 
 ### Fixed
 - `CategoricalParameter` and `TaskParameter` no longer incorrectly coerce a single

From 7687e39699af4273336dffda5b9f71e81400503b Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Fri, 23 Aug 2024 15:45:42 +0200
Subject: [PATCH 019/108] Ensure active parameters by altering parameters
 bounds

---
 baybe/searchspace/continuous.py               | 75 +++++++++----------
 .../test_constraints_continuous.py            |  5 +-
 2 files changed, 40 insertions(+), 40 deletions(-)

diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 049755b32..e98bd0a5f 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import math
+import sys
 import warnings
 from collections.abc import Collection, Iterable, Sequence
 from itertools import chain, product
@@ -32,6 +33,7 @@
 from baybe.serialization import SerialMixin, converter, select_constructor_hook
 from baybe.utils.basic import to_tuple
 from baybe.utils.dataframe import pretty_print_df
+from baybe.utils.interval import Interval
 from baybe.utils.numerical import DTypeFloatNumpy
 
 if TYPE_CHECKING:
@@ -306,16 +308,16 @@ def _drop_parameters(self, parameter_names: Collection[str]) -> SubspaceContinuo
 
     def _ensure_nonzero_parameters(
         self,
-        inactive_parameters: Collection[str],
-        inactivity_threshold: float = 1e-5,
+        inactive_parameter_names: Collection[str],
+        inactivity_threshold: float = sys.float_info.min,
     ) -> SubspaceContinuous:
         """Create a new subspace with following several actions.
 
-        * Ensure active parameter != 0.0.
-        * Remove cardinality constraint.
+        * Remove cardinality constraints.
+        * Ensure active parameters != 0.0 when its bounds locate on zero.
 
         Args:
-            inactive_parameters: A list of inactive parameters.
+            inactive_parameter_names: A list of inactive parameters.
             inactivity_threshold: Threshold for checking whether a value is zero.
 
         Returns:
@@ -327,46 +329,43 @@ def _ensure_nonzero_parameters(
         # TODO: Shouldn't the x != 0 constraints be applied on the level of the
         #   individual constrains, also taking into account whether min_cardinality > 0?
 
-        # TODO: Instead of adding additional constraints, why not alter the parameter
-        #   bounds? In case we keep the constraints: is the sign of the threshold
-        #   correct?
+        def ensure_active_parameters(
+            parameters: tuple[NumericalContinuousParameter, ...],
+            active_parameter_names: Collection[str],
+        ) -> tuple[NumericalContinuousParameter, ...]:
+            parameters_active_guaranteed = []
+            for p in parameters:
+                if p.name not in active_parameter_names:
+                    bounds = p.bounds
+                # Active parameter x with bounds [..., 0], ensure x != 0
+                elif p.bounds.upper == 0.0:
+                    bounds = Interval(lower=p.bounds.lower, upper=inactivity_threshold)
+                # Active parameter x with bounds [0, ...], ensure x != 0
+                elif p.bounds.lower == 0.0:
+                    bounds = Interval(lower=inactivity_threshold, upper=p.bounds.upper)
+                # TODO: For active parameter x in [..., 0, ...], ensure x != 0 is not
+                #  done.
+                else:
+                    bounds = p.bounds
+                parameters_active_guaranteed.append(
+                    NumericalContinuousParameter(
+                        name=p.name,
+                        bounds=bounds,
+                    )
+                )
+            return tuple(parameters_active_guaranteed)
 
         # Active parameters: parameters involved in cardinality constraints
         active_parameter_names = set(
             self.param_names_in_cardinality_constraint
-        ).difference(set(inactive_parameters))
-
-        constraints_lin_ineq = list(self.constraints_lin_ineq)
-        for name in active_parameter_names:
-            parameter = next(p for p in self.parameters if p.name == name)
-
-            # TODO: Ensure x != 0 when x in [..., 0, ...] is not done. Do we need it?
-            # TODO: To ensure the minimum cardinality constraints, shall we keep the x
-            #  != 0 operations or shall we instead skip the invalid results at the end
-            # Ensure x != 0 when bounds = [..., 0]. This is needed, otherwise
-            # the minimum cardinality constraint is easily violated
-            if parameter.bounds.upper == 0:
-                constraints_lin_ineq.append(
-                    ContinuousLinearInequalityConstraint(
-                        parameters=[name],
-                        coefficients=[-1.0],
-                        rhs=min(inactivity_threshold, -parameter.bounds.lower),
-                    )
-                )
-            # Ensure x != 0 when bounds = [0, ...]
-            elif parameter.bounds.lower == 0:
-                constraints_lin_ineq.append(
-                    ContinuousLinearInequalityConstraint(
-                        parameters=[name],
-                        coefficients=[1.0],
-                        rhs=min(inactivity_threshold, parameter.bounds.upper),
-                    ),
-                )
+        ).difference(set(inactive_parameter_names))
 
         return SubspaceContinuous(
-            parameters=self.parameters,
+            parameters=ensure_active_parameters(
+                self.parameters, active_parameter_names
+            ),
             constraints_lin_eq=self.constraints_lin_eq,
-            constraints_lin_ineq=constraints_lin_ineq,
+            constraints_lin_ineq=self.constraints_lin_ineq,
         )
 
     def transform(
diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py
index 8d03726ff..b67d4796e 100644
--- a/tests/constraints/test_constraints_continuous.py
+++ b/tests/constraints/test_constraints_continuous.py
@@ -1,5 +1,7 @@
 """Test for imposing continuous constraints."""
 
+import sys
+
 import numpy as np
 import pytest
 
@@ -7,7 +9,6 @@
     ContinuousLinearEqualityConstraint,
     ContinuousLinearInequalityConstraint,
 )
-from baybe.searchspace.continuous import ZERO_THRESHOLD
 from tests.conftest import run_iterations
 from tests.constraints.test_cardinality_constraint_continuous import _validate_samples
 
@@ -87,7 +88,7 @@ def test_cardinality_constraint(campaign, n_iterations, batch_size):
             max_cardinality=MAX_CARDINALITY,
             min_cardinality=MIN_CARDINALITY,
             batch_size=batch_size,
-            threshold=ZERO_THRESHOLD,
+            threshold=sys.float_info.min,
         )
 
 

From 66c3278a4554df29da5bae651ade627df92414e0 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Fri, 23 Aug 2024 23:18:59 +0200
Subject: [PATCH 020/108] Fix continuous constraint test

---
 tests/constraints/test_constraints_continuous.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py
index b67d4796e..c6449caf8 100644
--- a/tests/constraints/test_constraints_continuous.py
+++ b/tests/constraints/test_constraints_continuous.py
@@ -71,7 +71,7 @@ def test_cardinality_constraint(campaign, n_iterations, batch_size):
     """Test cardinality constraint for both random recommender and botorch
     recommender."""  # noqa
 
-    MIN_CARDINALITY = 0
+    MIN_CARDINALITY = 1
     MAX_CARDINALITY = 2
     run_iterations(campaign, n_iterations, batch_size, add_noise=False)
     recommendations = campaign.measurements

From a1d11e799af0f736501c8052bad55bd71df3af53 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Fri, 23 Aug 2024 23:25:31 +0200
Subject: [PATCH 021/108] Refactor botorch interface using fixed parameter
 class

---
 baybe/parameters/numerical.py               | 23 ++++++++++++++
 baybe/recommenders/pure/bayesian/botorch.py | 33 +++++++++-----------
 baybe/searchspace/continuous.py             | 34 ++++++++++++++++-----
 3 files changed, 63 insertions(+), 27 deletions(-)

diff --git a/baybe/parameters/numerical.py b/baybe/parameters/numerical.py
index f1c7b3e46..8e90afa90 100644
--- a/baybe/parameters/numerical.py
+++ b/baybe/parameters/numerical.py
@@ -142,3 +142,26 @@ def summary(self) -> dict:  # noqa: D102
             Upper_Bound=self.bounds.upper,
         )
         return param_dict
+
+
+@define(frozen=True, slots=False)
+class _FixedNumericalContinuousParameter(ContinuousParameter):
+    """Parameter class for fixed numerical parameters."""
+
+    is_numeric: ClassVar[bool] = True
+    # See base class.
+
+    value: float = field(converter=float)
+    """The fixed value of the parameter."""
+
+    @property
+    def bounds(self) -> Interval:
+        """The value of the parameter as a degenerate interval."""
+        return Interval(self.value, self.value)
+
+    def is_in_range(self, item: float) -> bool:
+        # See base class.
+        return item == self.value
+
+    def summary(self) -> dict:
+        raise NotImplementedError()
diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 30f0525d9..2b103cce3 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -12,6 +12,7 @@
 
 from baybe.constraints import ContinuousCardinalityConstraint
 from baybe.exceptions import NoMCAcquisitionFunctionError
+from baybe.parameters.numerical import _FixedNumericalContinuousParameter
 from baybe.recommenders.pure.bayesian.base import BayesianRecommender
 from baybe.searchspace import (
     SearchSpace,
@@ -209,7 +210,7 @@ def _recommend_continuous_with_cardinality_constraints(
                 f"This method expects a subspace object with constraints of type "
                 f"{ContinuousCardinalityConstraint.__name__}. For a subspace object "
                 f"without constraints of type"
-                f" {ContinuousCardinalityConstraint.__name__}, "
+                f" {ContinuousCardinalityConstraint.__name__}, use method"
                 f"{self._recommend_continuous_without_cardinality_constraints.__name__}."  # noqa
             )
 
@@ -226,22 +227,24 @@ def append_recommendation_for_inactive_parameters_setting(
             """
             # Create a new subspace by ensuring all active parameters being
             # non-zeros.
-            subspace_continuous_with_active_params = (
-                subspace_continuous._ensure_nonzero_parameters(inactive_parameters)
+            subspace_continuous_without_cardinality_constraints = (
+                subspace_continuous._remove_cardinality_constraints(inactive_parameters)
             )
             # Optimize the acquisition function
             (
                 points_i,
                 acqf_values_i,
             ) = self._recommend_continuous_without_cardinality_constraints(
-                subspace_continuous_with_active_params,
+                subspace_continuous_without_cardinality_constraints,
                 batch_size,
-                inactive_parameters,
             )
             # Append recommendation list and acquisition function values
             points_all.append(points_i.unsqueeze(0))
             acqf_values_all.append(acqf_values_i.unsqueeze(0))
 
+        # TODO: For certain setting of inactive parameters, the resulting problem may
+        #  be infeasible. Add "try" section to handle it.
+
         # Below we start recommendation
         if (
             subspace_continuous.n_inactive_parameter_combinations
@@ -275,7 +278,6 @@ def _recommend_continuous_without_cardinality_constraints(
         self,
         subspace_continuous: SubspaceContinuous,
         batch_size: int,
-        inactive_parameters: tuple[str, ...] | None = None,
     ) -> tuple[Tensor, Tensor]:
         """Recommend from a continuous search space without cardinality constraints.
 
@@ -283,7 +285,6 @@ def _recommend_continuous_without_cardinality_constraints(
             subspace_continuous: The continuous subspace from which to generate
                 recommendations.
             batch_size: The size of the recommendation batch.
-            inactive_parameters: A list of inactive parameters.
 
         Returns:
             The recommendations.
@@ -304,17 +305,11 @@ def _recommend_continuous_without_cardinality_constraints(
                 f"try method {self._recommend_continuous.__name__}."
             )
 
-        if not inactive_parameters:
-            fixed_parameters = None
-        else:
-            # Cast the inactive parameters to the format of fixed features used
-            # in optimize_acqf())
-            indices_inactive_params = [
-                subspace_continuous.param_names.index(key)
-                for key in subspace_continuous.param_names
-                if key in inactive_parameters
-            ]
-            fixed_parameters = {ind: 0.0 for ind in indices_inactive_params}
+        fixed_parameters = {
+            idx: p.value
+            for (idx, p) in enumerate(subspace_continuous.parameters)
+            if isinstance(p, _FixedNumericalContinuousParameter)
+        }
 
         points, acqf_values = optimize_acqf(
             acq_function=self._botorch_acqf,
@@ -322,7 +317,7 @@ def _recommend_continuous_without_cardinality_constraints(
             q=batch_size,
             num_restarts=5,  # TODO make choice for num_restarts
             raw_samples=10,  # TODO make choice for raw_samples
-            fixed_features=fixed_parameters,
+            fixed_features=fixed_parameters or None,
             equality_constraints=[
                 c.to_botorch(subspace_continuous.parameters)
                 for c in subspace_continuous.constraints_lin_eq
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index e98bd0a5f..3c0113892 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -25,6 +25,7 @@
 )
 from baybe.parameters import NumericalContinuousParameter
 from baybe.parameters.base import ContinuousParameter
+from baybe.parameters.numerical import _FixedNumericalContinuousParameter
 from baybe.parameters.utils import get_parameters_from_dataframe
 from baybe.searchspace.validation import (
     get_transform_parameters,
@@ -306,22 +307,19 @@ def _drop_parameters(self, parameter_names: Collection[str]) -> SubspaceContinuo
             ],
         )
 
-    def _ensure_nonzero_parameters(
+    def _remove_cardinality_constraints(
         self,
         inactive_parameter_names: Collection[str],
         inactivity_threshold: float = sys.float_info.min,
     ) -> SubspaceContinuous:
-        """Create a new subspace with following several actions.
-
-        * Remove cardinality constraints.
-        * Ensure active parameters != 0.0 when its bounds locate on zero.
+        """Create a copy of the subspace with cardinality constraints removed.
 
         Args:
             inactive_parameter_names: A list of inactive parameters.
             inactivity_threshold: Threshold for checking whether a value is zero.
 
         Returns:
-            A new subspace object.
+            A new subspace object without cardinality constraints.
         """
         # TODO: Revise function name/docstring and arguments. In particular: why
         #   does the function expect the inactive parameters instead of the active ones?
@@ -329,10 +327,21 @@ def _ensure_nonzero_parameters(
         # TODO: Shouldn't the x != 0 constraints be applied on the level of the
         #   individual constrains, also taking into account whether min_cardinality > 0?
 
+        # TODO: Merge _drop_parameters() to this method.
         def ensure_active_parameters(
             parameters: tuple[NumericalContinuousParameter, ...],
             active_parameter_names: Collection[str],
         ) -> tuple[NumericalContinuousParameter, ...]:
+            """Ensure certain parameters being non-zero by adjusting bounds.
+
+            Args:
+                parameters: A list of parameters.
+                active_parameter_names: A list of parameters names that must be
+                    non-zero.
+
+            Returns:
+                A list of parameters with certain parameters guaranteed to be non-zero.
+            """
             parameters_active_guaranteed = []
             for p in parameters:
                 if p.name not in active_parameter_names:
@@ -360,9 +369,18 @@ def ensure_active_parameters(
             self.param_names_in_cardinality_constraint
         ).difference(set(inactive_parameter_names))
 
+        active_parameters_guaranteed = ensure_active_parameters(
+            self.parameters, active_parameter_names
+        )
+
         return SubspaceContinuous(
-            parameters=ensure_active_parameters(
-                self.parameters, active_parameter_names
+            parameters=tuple(
+                [
+                    _FixedNumericalContinuousParameter(name=p.name, value=0.0)
+                    if p.name in inactive_parameter_names
+                    else p
+                    for p in active_parameters_guaranteed
+                ]
             ),
             constraints_lin_eq=self.constraints_lin_eq,
             constraints_lin_ineq=self.constraints_lin_ineq,

From 22fd94243ccefdf15f5f5e86fe1c5548986642e4 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Mon, 26 Aug 2024 10:08:21 +0200
Subject: [PATCH 022/108] Add try-except block to handle infeasible problem at
 certain inactive parameter setting

---
 baybe/recommenders/pure/bayesian/botorch.py | 32 ++++++++++++---------
 1 file changed, 18 insertions(+), 14 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 2b103cce3..653744e11 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -230,20 +230,24 @@ def append_recommendation_for_inactive_parameters_setting(
             subspace_continuous_without_cardinality_constraints = (
                 subspace_continuous._remove_cardinality_constraints(inactive_parameters)
             )
-            # Optimize the acquisition function
-            (
-                points_i,
-                acqf_values_i,
-            ) = self._recommend_continuous_without_cardinality_constraints(
-                subspace_continuous_without_cardinality_constraints,
-                batch_size,
-            )
-            # Append recommendation list and acquisition function values
-            points_all.append(points_i.unsqueeze(0))
-            acqf_values_all.append(acqf_values_i.unsqueeze(0))
-
-        # TODO: For certain setting of inactive parameters, the resulting problem may
-        #  be infeasible. Add "try" section to handle it.
+            try:
+                # Optimize the acquisition function
+                (
+                    points_i,
+                    acqf_values_i,
+                ) = self._recommend_continuous_without_cardinality_constraints(
+                    subspace_continuous_without_cardinality_constraints,
+                    batch_size,
+                )
+                # Append recommendation list and acquisition function values
+                points_all.append(points_i.unsqueeze(0))
+                acqf_values_all.append(acqf_values_i.unsqueeze(0))
+
+            # The optimization problem may be infeasible for certain inactive
+            # parameters. The optimize_acqf raises a ValueError when the optimization
+            # problem is infeasible.
+            except ValueError:
+                pass
 
         # Below we start recommendation
         if (

From 120d71782e933028deef31827ca41f7f1a132324 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Mon, 26 Aug 2024 10:13:55 +0200
Subject: [PATCH 023/108] Update CHANGELOG.md

---
 CHANGELOG.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4de24b099..ef817bc64 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Utilities `inactive_parameter_combinations` and`n_inactive_parameter_combinations` 
   in both `ContinuousCardinalityConstraint`and `SubspaceContinuous`
 - Attribute `n_threshold_inactive_parameters_generator` added to `BotorchRecommender`
+- Class `_FixedNumericalContinuousParameter`
 
 ### Fixed
 - `CategoricalParameter` and `TaskParameter` no longer incorrectly coerce a single

From e6248b59d1bc654704011d9b9dac9a3b77887a43 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Mon, 26 Aug 2024 11:03:58 +0200
Subject: [PATCH 024/108] Fix type hint

---
 baybe/constraints/continuous.py             | 4 ++--
 baybe/recommenders/pure/bayesian/botorch.py | 3 ++-
 2 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py
index c50193910..343cea239 100644
--- a/baybe/constraints/continuous.py
+++ b/baybe/constraints/continuous.py
@@ -1,7 +1,7 @@
 """Continuous constraints."""
 
 import math
-from collections.abc import Iterator
+from collections.abc import Iterable, Iterator
 from itertools import combinations
 from math import comb
 
@@ -57,7 +57,7 @@ def n_inactive_parameter_combinations(self) -> int:
             for n_inactive_parameters in self._inactive_set_sizes()
         )
 
-    def _inactive_set_sizes(self) -> Iterator[int]:
+    def _inactive_set_sizes(self) -> Iterable[int]:
         """Iterate over all possible sizes of inactive parameter sets."""
         return range(
             len(self.parameters) - self.max_cardinality,
diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 653744e11..bd2f1c8af 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import math
+from collections.abc import Collection
 from typing import TYPE_CHECKING, Any, ClassVar
 
 import pandas as pd
@@ -218,7 +219,7 @@ def _recommend_continuous_with_cardinality_constraints(
         points_all: list[Tensor] = []
 
         def append_recommendation_for_inactive_parameters_setting(
-            inactive_parameters: tuple[str, ...],
+            inactive_parameters: Collection[str],
         ):
             """Append the recommendation for each inactive parameter configuration.
 

From e0508b902d8ecb2508a43c054001eb9d73936876 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Mon, 26 Aug 2024 12:21:42 +0200
Subject: [PATCH 025/108] Fix test by repacing match text

---
 tests/test_searchspace.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/test_searchspace.py b/tests/test_searchspace.py
index 73359943f..eb19f15bb 100644
--- a/tests/test_searchspace.py
+++ b/tests/test_searchspace.py
@@ -290,7 +290,7 @@ def test_cardinality_constraint_with_invalid_parameter_bounds():
         NumericalContinuousParameter("c1", (0, 1)),
         NumericalContinuousParameter("c2", (1, 2)),
     )
-    with pytest.raises(ValueError, match="must cover zero"):
+    with pytest.raises(ValueError, match="must include zero"):
         SubspaceContinuous(
             parameters=parameters,
             constraints_nonlin=(

From 04d89d10522c4e90af412f095ce8691d042001f9 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 15 Oct 2024 11:28:38 +0200
Subject: [PATCH 026/108] Refine docstrings

---
 baybe/constraints/continuous.py | 4 ++--
 baybe/searchspace/continuous.py | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py
index 343cea239..70a3aaf7a 100644
--- a/baybe/constraints/continuous.py
+++ b/baybe/constraints/continuous.py
@@ -58,14 +58,14 @@ def n_inactive_parameter_combinations(self) -> int:
         )
 
     def _inactive_set_sizes(self) -> Iterable[int]:
-        """Iterate over all possible sizes of inactive parameter sets."""
+        """Get all possible sizes of inactive parameter sets."""
         return range(
             len(self.parameters) - self.max_cardinality,
             len(self.parameters) - self.min_cardinality + 1,
         )
 
     def inactive_parameter_combinations(self) -> Iterator[frozenset[str]]:
-        """Iterate over all possible combinations of inactive parameters."""
+        """Get an iterator over all possible combinations of inactive parameters."""
         for n_inactive_parameters in self._inactive_set_sizes():
             yield from combinations(self.parameters, n_inactive_parameters)
 
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 3c0113892..ebc86ca21 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -122,7 +122,7 @@ def n_inactive_parameter_combinations(self) -> int:
         )
 
     def inactive_parameter_combinations(self) -> Iterable[frozenset[str]]:
-        """Iterate over all possible combinations of inactive parameters."""
+        """Get an iterator over all possible combinations of inactive parameters."""
         for combination in product(
             *[
                 con.inactive_parameter_combinations()

From 102ef07907bafbd2cd0d555113e82aa187116ead Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 15 Oct 2024 11:28:54 +0200
Subject: [PATCH 027/108] Fix method return type

---
 baybe/constraints/continuous.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py
index 70a3aaf7a..aca1c907e 100644
--- a/baybe/constraints/continuous.py
+++ b/baybe/constraints/continuous.py
@@ -1,7 +1,7 @@
 """Continuous constraints."""
 
 import math
-from collections.abc import Iterable, Iterator
+from collections.abc import Iterator
 from itertools import combinations
 from math import comb
 
@@ -57,7 +57,7 @@ def n_inactive_parameter_combinations(self) -> int:
             for n_inactive_parameters in self._inactive_set_sizes()
         )
 
-    def _inactive_set_sizes(self) -> Iterable[int]:
+    def _inactive_set_sizes(self) -> range:
         """Get all possible sizes of inactive parameter sets."""
         return range(
             len(self.parameters) - self.max_cardinality,

From 3d72f04f7b4aceabb17839e5c53c05561e5a51f4 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Fri, 25 Oct 2024 13:32:02 +0200
Subject: [PATCH 028/108] Fix capitalization in exception group

---
 baybe/constraints/validation.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/baybe/constraints/validation.py b/baybe/constraints/validation.py
index 17a2489a9..f1dcc0986 100644
--- a/baybe/constraints/validation.py
+++ b/baybe/constraints/validation.py
@@ -149,4 +149,4 @@ def validate_cardinality_constraint_parameter_bounds(
     if exceptions:
         if len(exceptions) == 1:
             raise exceptions[0]
-        raise ExceptionGroup("invalid parameter bounds", exceptions)
+        raise ExceptionGroup("Invalid parameter bounds", exceptions)

From ed8054ba9c575f91cd7d9399ebfe701edd571d85 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Fri, 25 Oct 2024 13:49:22 +0200
Subject: [PATCH 029/108] Add explicit error handling to validator

---
 baybe/constraints/validation.py | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/baybe/constraints/validation.py b/baybe/constraints/validation.py
index f1dcc0986..8c80a172f 100644
--- a/baybe/constraints/validation.py
+++ b/baybe/constraints/validation.py
@@ -133,8 +133,13 @@ def validate_cardinality_constraint_parameter_bounds(
     """
     exceptions = []
     for name in constraint.parameters:
-        # We implicitly assume that the corresponding parameter exists
-        parameter = next(p for p in parameters if p.name == name)
+        try:
+            parameter = next(p for p in parameters if p.name == name)
+        except StopIteration as ex:
+            raise ValueError(
+                f"The parameter '{name}' referenced by the constraint is not contained "
+                f"in the given collection of parameters."
+            ) from ex
 
         if not parameter.is_in_range(0.0):
             exceptions.append(

From 756bb09d3cdd8e1fb45480673c082695a9d5c15d Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Fri, 25 Oct 2024 14:19:19 +0200
Subject: [PATCH 030/108] Clean up cardinality constraint helper property

---
 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 0d23e859d..1c933854c 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -300,13 +300,10 @@ def comp_rep_columns(self) -> tuple[str, ...]:
         return tuple(chain.from_iterable(p.comp_rep_columns for p in self.parameters))
 
     @property
-    def param_names_in_cardinality_constraint(self) -> tuple[str, ...]:
-        """Return list of parameter names involved in cardinality constraints."""
-        # TODO: Is this property really needed? If so, apply naming conventions.
-        params_per_cardinatliy_constraint = [
-            c.parameters for c in self.constraints_cardinality
-        ]
-        return tuple(chain(*params_per_cardinatliy_constraint))
+    def parameter_names_in_cardinality_constraints(self) -> tuple[str, ...]:
+        """The names of all parameters affected by cardinality constraints."""
+        names_per_constraint = (c.parameters for c in self.constraints_cardinality)
+        return tuple(chain(*names_per_constraint))
 
     @property
     def comp_rep_bounds(self) -> pd.DataFrame:
@@ -394,7 +391,7 @@ def ensure_active_parameters(
 
         # Active parameters: parameters involved in cardinality constraints
         active_parameter_names = set(
-            self.param_names_in_cardinality_constraint
+            self.parameter_names_in_cardinality_constraints
         ).difference(set(inactive_parameter_names))
 
         active_parameters_guaranteed = ensure_active_parameters(

From b0c422e6dc1a87373ba907467d1d5189cae4f3d6 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Fri, 25 Oct 2024 15:26:37 +0200
Subject: [PATCH 031/108] Refactor parameter activation logic

---
 baybe/parameters/utils.py       | 50 +++++++++++++++++++++++++++++++
 baybe/searchspace/continuous.py | 53 +++++++--------------------------
 2 files changed, 61 insertions(+), 42 deletions(-)

diff --git a/baybe/parameters/utils.py b/baybe/parameters/utils.py
index ec33ce455..31d4f1d43 100644
--- a/baybe/parameters/utils.py
+++ b/baybe/parameters/utils.py
@@ -4,8 +4,10 @@
 from typing import Any, TypeVar
 
 import pandas as pd
+from attrs import evolve
 
 from baybe.parameters.base import Parameter
+from baybe.parameters.numerical import NumericalContinuousParameter
 
 _TParameter = TypeVar("_TParameter", bound=Parameter)
 
@@ -87,3 +89,51 @@ def get_parameters_from_dataframe(
 def sort_parameters(parameters: Collection[Parameter]) -> tuple[Parameter, ...]:
     """Sort parameters alphabetically by their names."""
     return tuple(sorted(parameters, key=lambda p: p.name))
+
+
+def activate_parameter(
+    parameter: NumericalContinuousParameter, threshold: float
+) -> NumericalContinuousParameter:
+    """Activates a given parameter by moving its bounds away from zero.
+
+    Important:
+        Parameters whose ranges include zero but whose bounds do not overlap with the
+        inactive range (i.e. parameters that contain the value zero far from their
+        boundary values) remain unchanged, because the corresponding activated parameter
+        would no longer have a continuous value range.
+
+    Args:
+        parameter: The parameter to be activated.
+        threshold: The threshold for a parameter to be considered active.
+
+    Returns:
+        A copy of the parameter with adjusted bounds.
+
+    Raises:
+        ValueError: If the parameter cannot be activated since both its bounds are
+            in the inactive range.
+    """
+    lower = parameter.bounds.lower
+    upper = parameter.bounds.upper
+
+    def in_inactive_range(x: float, /) -> bool:
+        return -threshold <= x <= threshold
+
+    # Upper bound is in inactive range
+    if lower < -threshold and in_inactive_range(upper):
+        return evolve(parameter, bounds=(lower, -threshold))
+
+    # Lower bound is in inactive range
+    if upper > threshold and in_inactive_range(lower):
+        return evolve(parameter, bounds=(threshold, upper))
+
+    # Both bounds in inactive range
+    if in_inactive_range(lower) and in_inactive_range(upper):
+        raise ValueError(
+            f"Parameter '{parameter.name}' cannot be set active since its "
+            f"bounds {parameter.bounds.to_tuple()} are entirely contained in the "
+            f"inactive range [-{threshold}, {threshold}]."
+        )
+
+    # Both bounds separated from inactive range
+    return parameter
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 1c933854c..c569c75ec 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -27,14 +27,17 @@
 from baybe.parameters import NumericalContinuousParameter
 from baybe.parameters.base import ContinuousParameter
 from baybe.parameters.numerical import _FixedNumericalContinuousParameter
-from baybe.parameters.utils import get_parameters_from_dataframe, sort_parameters
+from baybe.parameters.utils import (
+    activate_parameter,
+    get_parameters_from_dataframe,
+    sort_parameters,
+)
 from baybe.searchspace.validation import (
     validate_parameter_names,
 )
 from baybe.serialization import SerialMixin, converter, select_constructor_hook
 from baybe.utils.basic import to_tuple
 from baybe.utils.dataframe import get_transform_objects, pretty_print_df
-from baybe.utils.interval import Interval
 from baybe.utils.plotting import to_string
 
 if TYPE_CHECKING:
@@ -352,51 +355,17 @@ def _remove_cardinality_constraints(
         # TODO: Shouldn't the x != 0 constraints be applied on the level of the
         #   individual constrains, also taking into account whether min_cardinality > 0?
 
-        # TODO: Merge _drop_parameters() to this method.
-        def ensure_active_parameters(
-            parameters: tuple[NumericalContinuousParameter, ...],
-            active_parameter_names: Collection[str],
-        ) -> tuple[NumericalContinuousParameter, ...]:
-            """Ensure certain parameters being non-zero by adjusting bounds.
-
-            Args:
-                parameters: A list of parameters.
-                active_parameter_names: A list of parameters names that must be
-                    non-zero.
-
-            Returns:
-                A list of parameters with certain parameters guaranteed to be non-zero.
-            """
-            parameters_active_guaranteed = []
-            for p in parameters:
-                if p.name not in active_parameter_names:
-                    bounds = p.bounds
-                # Active parameter x with bounds [..., 0], ensure x != 0
-                elif p.bounds.upper == 0.0:
-                    bounds = Interval(lower=p.bounds.lower, upper=inactivity_threshold)
-                # Active parameter x with bounds [0, ...], ensure x != 0
-                elif p.bounds.lower == 0.0:
-                    bounds = Interval(lower=inactivity_threshold, upper=p.bounds.upper)
-                # TODO: For active parameter x in [..., 0, ...], ensure x != 0 is not
-                #  done.
-                else:
-                    bounds = p.bounds
-                parameters_active_guaranteed.append(
-                    NumericalContinuousParameter(
-                        name=p.name,
-                        bounds=bounds,
-                    )
-                )
-            return tuple(parameters_active_guaranteed)
-
         # Active parameters: parameters involved in cardinality constraints
         active_parameter_names = set(
             self.parameter_names_in_cardinality_constraints
         ).difference(set(inactive_parameter_names))
 
-        active_parameters_guaranteed = ensure_active_parameters(
-            self.parameters, active_parameter_names
-        )
+        active_parameters_guaranteed = [
+            activate_parameter(p, inactivity_threshold)
+            if p.name in active_parameter_names
+            else p
+            for p in self.parameters
+        ]
 
         return SubspaceContinuous(
             parameters=tuple(

From ddabcf5324f763e1c788745592a15d5c15f77dbb Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Fri, 25 Oct 2024 16:12:58 +0200
Subject: [PATCH 032/108] Refactor method for enforcing cardinality constraints

---
 baybe/recommenders/pure/bayesian/botorch.py |  4 +-
 baybe/searchspace/continuous.py             | 65 +++++++++------------
 2 files changed, 32 insertions(+), 37 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index d647a2ed7..192e234ae 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -252,7 +252,9 @@ def append_recommendation_for_inactive_parameters_setting(
             # Create a new subspace by ensuring all active parameters being
             # non-zeros.
             subspace_continuous_without_cardinality_constraints = (
-                subspace_continuous._remove_cardinality_constraints(inactive_parameters)
+                subspace_continuous._enforce_cardinality_constraints_via_assignment(
+                    inactive_parameters
+                )
             )
             try:
                 # Optimize the acquisition function
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index c569c75ec..15398dc58 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -4,7 +4,6 @@
 
 import gc
 import math
-import sys
 import warnings
 from collections.abc import Collection, Iterable, Sequence
 from itertools import chain, product
@@ -12,7 +11,7 @@
 
 import numpy as np
 import pandas as pd
-from attrs import define, field, fields
+from attrs import define, evolve, field, fields
 from typing_extensions import override
 
 from baybe.constraints import (
@@ -335,50 +334,44 @@ def _drop_parameters(self, parameter_names: Collection[str]) -> SubspaceContinuo
             ],
         )
 
-    def _remove_cardinality_constraints(
+    def _enforce_cardinality_constraints_via_assignment(
         self,
         inactive_parameter_names: Collection[str],
-        inactivity_threshold: float = sys.float_info.min,
+        threshold: float = 1e-8,
     ) -> SubspaceContinuous:
-        """Create a copy of the subspace with cardinality constraints removed.
+        """Create a copy of the subspace with fixed inactive parameters.
+
+        The returned subspace requires no cardinality constraints since – for the
+        given separation of parameter into active an inactive sets – the
+        cardinality constraints are implemented by fixing the inactive parameters to
+        zero and bounding the active parameters away from zero.
 
         Args:
-            inactive_parameter_names: A list of inactive parameters.
-            inactivity_threshold: Threshold for checking whether a value is zero.
+            inactive_parameter_names: The names of the parameter to be inactivated.
+            threshold: The threshold for a parameter to be considered active.
 
         Returns:
-            A new subspace object without cardinality constraints.
+            A new subspace with fixed inactive parameters and no cardinality
+            constraints.
         """
-        # TODO: Revise function name/docstring and arguments. In particular: why
-        #   does the function expect the inactive parameters instead of the active ones?
-
-        # TODO: Shouldn't the x != 0 constraints be applied on the level of the
-        #   individual constrains, also taking into account whether min_cardinality > 0?
-
-        # Active parameters: parameters involved in cardinality constraints
+        # Extract active parameters involved in cardinality constraints
         active_parameter_names = set(
             self.parameter_names_in_cardinality_constraints
-        ).difference(set(inactive_parameter_names))
-
-        active_parameters_guaranteed = [
-            activate_parameter(p, inactivity_threshold)
-            if p.name in active_parameter_names
-            else p
-            for p in self.parameters
-        ]
-
-        return SubspaceContinuous(
-            parameters=tuple(
-                [
-                    _FixedNumericalContinuousParameter(name=p.name, value=0.0)
-                    if p.name in inactive_parameter_names
-                    else p
-                    for p in active_parameters_guaranteed
-                ]
-            ),
-            constraints_lin_eq=self.constraints_lin_eq,
-            constraints_lin_ineq=self.constraints_lin_ineq,
-        )
+        ).difference(inactive_parameter_names)
+
+        # Adjust parameters depending on their in-/activity assignment
+        adjusted_parameters: list[ContinuousParameter] = []
+        p_adjusted: ContinuousParameter
+        for p in self.parameters:
+            if p.name in inactive_parameter_names:
+                p_adjusted = _FixedNumericalContinuousParameter(name=p.name, value=0.0)
+            elif p.name in active_parameter_names:
+                p_adjusted = activate_parameter(p, threshold)
+            else:
+                p_adjusted = p
+            adjusted_parameters.append(p_adjusted)
+
+        return evolve(self, parameters=adjusted_parameters, constraints_nonlin=())
 
     def transform(
         self,

From b8d24d7dfa1aed15ceb5b16a287ac3bce5e1ee7e Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Mon, 28 Oct 2024 18:19:12 +0100
Subject: [PATCH 033/108] Fix exception types and messages

---
 baybe/recommenders/pure/bayesian/botorch.py | 24 +++++++++------------
 1 file changed, 10 insertions(+), 14 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 192e234ae..17d10303a 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -225,17 +225,15 @@ def _recommend_continuous_with_cardinality_constraints(
             The acquisition values.
 
         Raises:
-            RuntimeError: If the continuous search space has no cardinality constraint.
+            ValueError: If the continuous search space has no cardinality constraints.
         """
         import torch
 
         if not subspace_continuous.constraints_cardinality:
-            raise RuntimeError(
-                f"This method expects a subspace object with constraints of type "
-                f"{ContinuousCardinalityConstraint.__name__}. For a subspace object "
-                f"without constraints of type"
-                f" {ContinuousCardinalityConstraint.__name__}, use method"
-                f"{self._recommend_continuous_without_cardinality_constraints.__name__}."  # noqa
+            raise ValueError(
+                f"'{self._recommend_continuous_with_cardinality_constraints.__name__}' "
+                f"expects a subspace with constraints of type "
+                f"'{ContinuousCardinalityConstraint.__name__}'. "
             )
 
         acqf_values_all: list[Tensor] = []
@@ -321,18 +319,16 @@ def _recommend_continuous_without_cardinality_constraints(
             The acquisition values.
 
         Raises:
-            RuntimeError: If the continuous search space has any cardinality
-                constraints.
+            ValueError: If the continuous search space has cardinality constraints.
         """
         import torch
         from botorch.optim import optimize_acqf
 
         if subspace_continuous.constraints_cardinality:
-            raise RuntimeError(
-                f"This method expects only subspace object without constraints of type "
-                f"{ContinuousCardinalityConstraint.__name__}. For a subspace object "
-                f"with constraints of type {ContinuousCardinalityConstraint.__name__}, "
-                f"try method {self._recommend_continuous.__name__}."
+            raise ValueError(
+                f"'{self._recommend_continuous_without_cardinality_constraints.__name__}' "  # noqa: E501
+                f"expects a subspace without constraints of type "
+                f"'{ContinuousCardinalityConstraint.__name__}'. "
             )
 
         fixed_parameters = {

From 492bb3b8f2013ba1a9859a9acffa932ee8a760a5 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Mon, 28 Oct 2024 18:20:58 +0100
Subject: [PATCH 034/108] Apply minor formatting and documentation fixes

---
 baybe/recommenders/pure/bayesian/botorch.py | 20 +++++++-------------
 1 file changed, 7 insertions(+), 13 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 17d10303a..6e3d17ad8 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -186,7 +186,6 @@ def _recommend_continuous(
         Returns:
             A dataframe containing the recommendations as individual rows.
         """
-        # For batch size > 1, this optimizer needs a MC acquisition function
         if batch_size > 1 and not self.acquisition_function.is_mc:
             raise IncompatibleAcquisitionFunctionError(
                 f"The '{self.__class__.__name__}' only works with Monte Carlo "
@@ -195,18 +194,14 @@ def _recommend_continuous(
 
         if len(subspace_continuous.constraints_cardinality):
             points, _ = self._recommend_continuous_with_cardinality_constraints(
-                subspace_continuous,
-                batch_size,
+                subspace_continuous, batch_size
             )
         else:
             points, _ = self._recommend_continuous_without_cardinality_constraints(
-                subspace_continuous,
-                batch_size,
+                subspace_continuous, batch_size
             )
 
-        # Return optimized points as dataframe
-        rec = pd.DataFrame(points, columns=subspace_continuous.parameter_names)
-        return rec
+        return pd.DataFrame(points, columns=subspace_continuous.parameter_names)
 
     def _recommend_continuous_with_cardinality_constraints(
         self,
@@ -221,8 +216,7 @@ def _recommend_continuous_with_cardinality_constraints(
             batch_size: The size of the recommendation batch.
 
         Returns:
-            The recommendations.
-            The acquisition values.
+            The recommendations and corresponding acquisition values.
 
         Raises:
             ValueError: If the continuous search space has no cardinality constraints.
@@ -315,8 +309,7 @@ def _recommend_continuous_without_cardinality_constraints(
             batch_size: The size of the recommendation batch.
 
         Returns:
-            The recommendations.
-            The acquisition values.
+            The recommendations and corresponding acquisition values.
 
         Raises:
             ValueError: If the continuous search space has cardinality constraints.
@@ -343,7 +336,9 @@ def _recommend_continuous_without_cardinality_constraints(
             q=batch_size,
             num_restarts=self.n_restarts,
             raw_samples=self.n_raw_samples,
+            # TODO: https://github.com/pytorch/botorch/issues/2042
             fixed_features=fixed_parameters or None,
+            # TODO: https://github.com/pytorch/botorch/issues/2042
             equality_constraints=[
                 c.to_botorch(subspace_continuous.parameters)
                 for c in subspace_continuous.constraints_lin_eq
@@ -355,7 +350,6 @@ def _recommend_continuous_without_cardinality_constraints(
                 for c in subspace_continuous.constraints_lin_ineq
             ]
             or None,
-            # TODO: https://github.com/pytorch/botorch/issues/2042
             sequential=self.sequential_continuous,
         )
         return points, acqf_values

From 584d8d91d8ffb0985cd7e6287314f813850b025d Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Mon, 28 Oct 2024 18:21:22 +0100
Subject: [PATCH 035/108] Remove unnecessary `len` call

---
 baybe/recommenders/pure/bayesian/botorch.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 6e3d17ad8..ab895b2ec 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -192,7 +192,7 @@ def _recommend_continuous(
                 f"acquisition functions for batch sizes > 1."
             )
 
-        if len(subspace_continuous.constraints_cardinality):
+        if subspace_continuous.constraints_cardinality:
             points, _ = self._recommend_continuous_with_cardinality_constraints(
                 subspace_continuous, batch_size
             )

From 5744e31812c6dd6f5a27822321fb46f004dbb8f1 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Mon, 28 Oct 2024 18:22:06 +0100
Subject: [PATCH 036/108] Remove unnecessary function layer

---
 baybe/recommenders/pure/bayesian/botorch.py | 52 +++++++--------------
 1 file changed, 17 insertions(+), 35 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index ab895b2ec..3edc732ff 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -4,7 +4,7 @@
 
 import gc
 import math
-from collections.abc import Collection
+from collections.abc import Collection, Iterable
 from typing import TYPE_CHECKING, Any, ClassVar
 
 import pandas as pd
@@ -230,19 +230,25 @@ def _recommend_continuous_with_cardinality_constraints(
                 f"'{ContinuousCardinalityConstraint.__name__}'. "
             )
 
+        # Determine search scope based on number of inactive parameter combinations
+        exhaustive_search = (
+            subspace_continuous.n_inactive_parameter_combinations
+            <= self.n_threshold_inactive_parameters_generator
+        )
+        iterator: Iterable[Collection[str]]
+        if exhaustive_search:
+            # If manageable, evaluate all combinations of inactive parameters
+            iterator = subspace_continuous.inactive_parameter_combinations()
+        else:
+            # Otherwise, draw a random subset of inactive parameter combinations
+            iterator = subspace_continuous._sample_inactive_parameters(
+                self.n_threshold_inactive_parameters_generator
+            )
+
         acqf_values_all: list[Tensor] = []
         points_all: list[Tensor] = []
 
-        def append_recommendation_for_inactive_parameters_setting(
-            inactive_parameters: Collection[str],
-        ):
-            """Append the recommendation for each inactive parameter configuration.
-
-            Args:
-                inactive_parameters: A list of inactive parameters.
-            """
-            # Create a new subspace by ensuring all active parameters being
-            # non-zeros.
+        for inactive_parameters in iterator:
             subspace_continuous_without_cardinality_constraints = (
                 subspace_continuous._enforce_cardinality_constraints_via_assignment(
                     inactive_parameters
@@ -267,30 +273,6 @@ def append_recommendation_for_inactive_parameters_setting(
             except ValueError:
                 pass
 
-        # Below we start recommendation
-        if (
-            subspace_continuous.n_inactive_parameter_combinations
-            > self.n_threshold_inactive_parameters_generator
-        ):
-            # When the combinatorial list is too large, randomly set some parameters
-            # inactive.
-            for _ in range(self.n_threshold_inactive_parameters_generator):
-                inactive_params_sample = tuple(
-                    subspace_continuous._sample_inactive_parameters(1)[0]
-                )
-                append_recommendation_for_inactive_parameters_setting(
-                    inactive_params_sample
-                )
-        else:
-            # When the combinatorial list is not too large, iterate the combinatorial
-            # list of all possible inactive parameters.
-            for (
-                inactive_params_generator
-            ) in subspace_continuous.inactive_parameter_combinations():
-                append_recommendation_for_inactive_parameters_setting(
-                    inactive_params_generator
-                )
-
         # Find the best option
         points = torch.cat(points_all)[torch.argmax(torch.cat(acqf_values_all)), :]
         acqf_values = torch.max(torch.cat(acqf_values_all))

From e4afdccc852db1f6eb2fbc70875f2d3ebc4574e1 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Mon, 28 Oct 2024 18:47:55 +0100
Subject: [PATCH 037/108] Extract loop into general function optimizing
 subspaces

---
 baybe/recommenders/pure/bayesian/botorch.py | 67 ++++++++++++---------
 1 file changed, 38 insertions(+), 29 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 3edc732ff..73229adc4 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -221,8 +221,6 @@ def _recommend_continuous_with_cardinality_constraints(
         Raises:
             ValueError: If the continuous search space has no cardinality constraints.
         """
-        import torch
-
         if not subspace_continuous.constraints_cardinality:
             raise ValueError(
                 f"'{self._recommend_continuous_with_cardinality_constraints.__name__}' "
@@ -245,38 +243,19 @@ def _recommend_continuous_with_cardinality_constraints(
                 self.n_threshold_inactive_parameters_generator
             )
 
-        acqf_values_all: list[Tensor] = []
-        points_all: list[Tensor] = []
-
-        for inactive_parameters in iterator:
-            subspace_continuous_without_cardinality_constraints = (
+        # Create iterable of subspaces to be optimized
+        subspaces = (
+            (
                 subspace_continuous._enforce_cardinality_constraints_via_assignment(
                     inactive_parameters
                 )
             )
-            try:
-                # Optimize the acquisition function
-                (
-                    points_i,
-                    acqf_values_i,
-                ) = self._recommend_continuous_without_cardinality_constraints(
-                    subspace_continuous_without_cardinality_constraints,
-                    batch_size,
-                )
-                # Append recommendation list and acquisition function values
-                points_all.append(points_i.unsqueeze(0))
-                acqf_values_all.append(acqf_values_i.unsqueeze(0))
-
-            # The optimization problem may be infeasible for certain inactive
-            # parameters. The optimize_acqf raises a ValueError when the optimization
-            # problem is infeasible.
-            except ValueError:
-                pass
+            for inactive_parameters in iterator
+        )
 
-        # Find the best option
-        points = torch.cat(points_all)[torch.argmax(torch.cat(acqf_values_all)), :]
-        acqf_values = torch.max(torch.cat(acqf_values_all))
-        return points, acqf_values
+        return self._optimize_subspaces_without_cardinality_constraints(
+            subspaces, batch_size
+        )
 
     def _recommend_continuous_without_cardinality_constraints(
         self,
@@ -467,6 +446,36 @@ def __str__(self) -> str:
         ]
         return to_string(self.__class__.__name__, *fields)
 
+    def _optimize_subspaces_without_cardinality_constraints(
+        self, subspaces: Iterable[SubspaceContinuous], batch_size: int
+    ) -> tuple[Tensor, Tensor]:
+        import torch
+
+        acqf_values_all: list[Tensor] = []
+        points_all: list[Tensor] = []
+
+        for subspace in subspaces:
+            try:
+                # Optimize the acquisition function
+                f = self._recommend_continuous_without_cardinality_constraints
+                points_i, acqf_values_i = f(subspace, batch_size)
+
+                # Append recommendation list and acquisition function values
+                points_all.append(points_i.unsqueeze(0))
+                acqf_values_all.append(acqf_values_i.unsqueeze(0))
+
+            # # The optimization problem may be infeasible for certain inactive
+            # # parameters. The optimize_acqf raises a ValueError when the optimization
+            # # problem is infeasible.
+            except ValueError:
+                pass
+
+        # Find the best option
+        points = torch.cat(points_all)[torch.argmax(torch.cat(acqf_values_all)), :]
+        acqf_values = torch.max(torch.cat(acqf_values_all))
+
+        return points, acqf_values
+
 
 # Collect leftover original slotted classes processed by `attrs.define`
 gc.collect()

From 788a5ba14be7a2439f3bb08cdcf8f96d9253f938 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 29 Oct 2024 08:51:27 +0100
Subject: [PATCH 038/108] Simplify multi-space optimization logic

---
 baybe/recommenders/pure/bayesian/botorch.py | 29 +++++++++++++--------
 1 file changed, 18 insertions(+), 11 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 73229adc4..fe931888a 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -7,6 +7,7 @@
 from collections.abc import Collection, Iterable
 from typing import TYPE_CHECKING, Any, ClassVar
 
+import numpy as np
 import pandas as pd
 from attrs import define, field
 from attrs.converters import optional as optional_c
@@ -449,8 +450,15 @@ def __str__(self) -> str:
     def _optimize_subspaces_without_cardinality_constraints(
         self, subspaces: Iterable[SubspaceContinuous], batch_size: int
     ) -> tuple[Tensor, Tensor]:
-        import torch
+        """Find the optimum candidates from multiple subspaces.
+
+        Args:
+            subspaces: The subspaces to consider for the optimization.
+            batch_size: The number of points to be recommended.
 
+        Returns:
+            The batch of candidates and the corresponding acquisition value.
+        """
         acqf_values_all: list[Tensor] = []
         points_all: list[Tensor] = []
 
@@ -460,21 +468,20 @@ def _optimize_subspaces_without_cardinality_constraints(
                 f = self._recommend_continuous_without_cardinality_constraints
                 points_i, acqf_values_i = f(subspace, batch_size)
 
-                # Append recommendation list and acquisition function values
-                points_all.append(points_i.unsqueeze(0))
-                acqf_values_all.append(acqf_values_i.unsqueeze(0))
+                # Append optimization results
+                points_all.append(points_i)
+                acqf_values_all.append(acqf_values_i)
 
-            # # The optimization problem may be infeasible for certain inactive
-            # # parameters. The optimize_acqf raises a ValueError when the optimization
-            # # problem is infeasible.
+            # The optimization problem may be infeasible in certain subspaces
             except ValueError:
                 pass
 
-        # Find the best option
-        points = torch.cat(points_all)[torch.argmax(torch.cat(acqf_values_all)), :]
-        acqf_values = torch.max(torch.cat(acqf_values_all))
+        # Find the best option f
+        best_idx = np.argmax(acqf_values_all)
+        points = points_all[best_idx]
+        acqf_value = acqf_values_all[best_idx]
 
-        return points, acqf_values
+        return points, acqf_value
 
 
 # Collect leftover original slotted classes processed by `attrs.define`

From 046a8e2ec45baae8dfd55da655f348d21ccd197f Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 29 Oct 2024 18:12:34 +0100
Subject: [PATCH 039/108] Remove restriction on subspaces without cardinality
 constraints

---
 baybe/recommenders/pure/bayesian/botorch.py | 29 ++++++++++++---------
 1 file changed, 16 insertions(+), 13 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index fe931888a..4c13da97d 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -193,17 +193,23 @@ def _recommend_continuous(
                 f"acquisition functions for batch sizes > 1."
             )
 
+        points, _ = self._recommend_continuous_torch(subspace_continuous, batch_size)
+
+        return pd.DataFrame(points, columns=subspace_continuous.parameter_names)
+
+    def _recommend_continuous_torch(
+        self, subspace_continuous: SubspaceContinuous, batch_size: int
+    ) -> tuple[Tensor, Tensor]:
+        """Dispatcher selecting continuous optimization routine."""
         if subspace_continuous.constraints_cardinality:
-            points, _ = self._recommend_continuous_with_cardinality_constraints(
+            return self._recommend_continuous_with_cardinality_constraints(
                 subspace_continuous, batch_size
             )
         else:
-            points, _ = self._recommend_continuous_without_cardinality_constraints(
+            return self._recommend_continuous_without_cardinality_constraints(
                 subspace_continuous, batch_size
             )
 
-        return pd.DataFrame(points, columns=subspace_continuous.parameter_names)
-
     def _recommend_continuous_with_cardinality_constraints(
         self,
         subspace_continuous: SubspaceContinuous,
@@ -254,9 +260,7 @@ def _recommend_continuous_with_cardinality_constraints(
             for inactive_parameters in iterator
         )
 
-        return self._optimize_subspaces_without_cardinality_constraints(
-            subspaces, batch_size
-        )
+        return self._optimize_continuous_subspaces(subspaces, batch_size)
 
     def _recommend_continuous_without_cardinality_constraints(
         self,
@@ -447,10 +451,10 @@ def __str__(self) -> str:
         ]
         return to_string(self.__class__.__name__, *fields)
 
-    def _optimize_subspaces_without_cardinality_constraints(
+    def _optimize_continuous_subspaces(
         self, subspaces: Iterable[SubspaceContinuous], batch_size: int
     ) -> tuple[Tensor, Tensor]:
-        """Find the optimum candidates from multiple subspaces.
+        """Find the optimum candidates from multiple continuous subspaces.
 
         Args:
             subspaces: The subspaces to consider for the optimization.
@@ -465,12 +469,11 @@ def _optimize_subspaces_without_cardinality_constraints(
         for subspace in subspaces:
             try:
                 # Optimize the acquisition function
-                f = self._recommend_continuous_without_cardinality_constraints
-                points_i, acqf_values_i = f(subspace, batch_size)
+                p, acqf = self._recommend_continuous_torch(subspace, batch_size)
 
                 # Append optimization results
-                points_all.append(points_i)
-                acqf_values_all.append(acqf_values_i)
+                points_all.append(p)
+                acqf_values_all.append(acqf)
 
             # The optimization problem may be infeasible in certain subspaces
             except ValueError:

From 7aac4d318aee3d32b21ef4966998e1f1dfb37ec0 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 29 Oct 2024 18:19:44 +0100
Subject: [PATCH 040/108] Move __str__ method to top

---
 baybe/recommenders/pure/bayesian/botorch.py | 36 ++++++++++-----------
 1 file changed, 18 insertions(+), 18 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 4c13da97d..d282047a2 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -109,6 +109,24 @@ def _validate_percentage(  # noqa: DOC101, DOC103
                 f"Hybrid sampling percentage needs to be between 0 and 1 but is {value}"
             )
 
+    @override
+    def __str__(self) -> str:
+        fields = [
+            to_string("Surrogate", self._surrogate_model),
+            to_string(
+                "Acquisition function", self.acquisition_function, single_line=True
+            ),
+            to_string("Compatibility", self.compatibility, single_line=True),
+            to_string(
+                "Sequential continuous", self.sequential_continuous, single_line=True
+            ),
+            to_string("Hybrid sampler", self.hybrid_sampler, single_line=True),
+            to_string(
+                "Sampling percentage", self.sampling_percentage, single_line=True
+            ),
+        ]
+        return to_string(self.__class__.__name__, *fields)
+
     @override
     def _recommend_discrete(
         self,
@@ -433,24 +451,6 @@ def _recommend_hybrid(
 
         return rec_exp
 
-    @override
-    def __str__(self) -> str:
-        fields = [
-            to_string("Surrogate", self._surrogate_model),
-            to_string(
-                "Acquisition function", self.acquisition_function, single_line=True
-            ),
-            to_string("Compatibility", self.compatibility, single_line=True),
-            to_string(
-                "Sequential continuous", self.sequential_continuous, single_line=True
-            ),
-            to_string("Hybrid sampler", self.hybrid_sampler, single_line=True),
-            to_string(
-                "Sampling percentage", self.sampling_percentage, single_line=True
-            ),
-        ]
-        return to_string(self.__class__.__name__, *fields)
-
     def _optimize_continuous_subspaces(
         self, subspaces: Iterable[SubspaceContinuous], batch_size: int
     ) -> tuple[Tensor, Tensor]:

From 3c829d048991abc70380e0859da0974f792c5bf7 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 29 Oct 2024 20:44:47 +0100
Subject: [PATCH 041/108] Rename threshold attribute

---
 baybe/recommenders/pure/bayesian/botorch.py | 19 ++++++++-----------
 1 file changed, 8 insertions(+), 11 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index d282047a2..68f4b2289 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -85,15 +85,12 @@ class BotorchRecommender(BayesianRecommender):
     optimization. **Does not affect purely discrete optimization**.
     """
 
-    n_threshold_inactive_parameters_generator: int = field(
-        default=10, validator=[instance_of(int), ge(1)]
-    )
-    """Threshold used for checking which inactive parameters generator is used when
-    cardinality constraints are present. When the size of the combinatorial list of
-    all possible inactive parameters is larger than the threshold, a fixed number of
-    randomly generated inactive parameter configurations are used and the best
-    optimum among them is recommended; Otherwise, we find the best one by iterating the
-    combinatorial list of all possible inactive parameters """
+    max_n_subspaces: int = field(default=10, validator=[instance_of(int), ge(1)])
+    """Threshold defining the maximum number of subspaces to consider for exhaustive
+    search in the presence of cardinality constraints. If the combinatorial number of
+    groupings into active and inactive parameters dictated by the constraints is greater
+    than this number, that many randomly selected combinations are selected for
+    optimization."""
 
     @sampling_percentage.validator
     def _validate_percentage(  # noqa: DOC101, DOC103
@@ -256,7 +253,7 @@ def _recommend_continuous_with_cardinality_constraints(
         # Determine search scope based on number of inactive parameter combinations
         exhaustive_search = (
             subspace_continuous.n_inactive_parameter_combinations
-            <= self.n_threshold_inactive_parameters_generator
+            <= self.max_n_subspaces
         )
         iterator: Iterable[Collection[str]]
         if exhaustive_search:
@@ -265,7 +262,7 @@ def _recommend_continuous_with_cardinality_constraints(
         else:
             # Otherwise, draw a random subset of inactive parameter combinations
             iterator = subspace_continuous._sample_inactive_parameters(
-                self.n_threshold_inactive_parameters_generator
+                self.max_n_subspaces
             )
 
         # Create iterable of subspaces to be optimized

From bc697c09e9010b74cf61df786e0a3f5ab7c232a5 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 30 Oct 2024 08:08:33 +0100
Subject: [PATCH 042/108] Add item to README.md

---
 README.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/README.md b/README.md
index 91383dfd1..c11280842 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,7 @@ Besides functionality to perform a typical recommend-measure loop, BayBE's highl
 - 🎭 Hybrid (mixed continuous and discrete) spaces
 - 🚀 Transfer learning: Mix data from multiple campaigns and accelerate optimization
 - 🎰 Bandit models: Efficiently find the best among many options in noisy environments (e.g. A/B Testing)
+- 🔢 Cardinality constraints: Control the number of active factors in your design
 - 🌎 Distributed workflows: Run campaigns asynchronously with pending experiments
 - 🎓 Active learning: Perform smart data acquisition campaigns
 - ⚙️ Custom surrogate models: Enhance your predictions through mechanistic understanding

From b9038b82c98a30caafb1b32f0a2ddea893ed2435 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 30 Oct 2024 08:27:45 +0100
Subject: [PATCH 043/108] Implement summary method

---
 baybe/parameters/numerical.py | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/baybe/parameters/numerical.py b/baybe/parameters/numerical.py
index c1973eafa..439a80050 100644
--- a/baybe/parameters/numerical.py
+++ b/baybe/parameters/numerical.py
@@ -176,7 +176,11 @@ def comp_rep_columns(self) -> tuple[str, ...]:
 
     @override
     def summary(self) -> dict:
-        raise NotImplementedError()
+        return dict(
+            Name=self.name,
+            Type=self.__class__.__name__,
+            Value=self.value,
+        )
 
 
 # Collect leftover original slotted classes processed by `attrs.define`

From c2c8b99f066a10cceba635f02795348a328860dd Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Fri, 1 Nov 2024 10:35:41 +0100
Subject: [PATCH 044/108] Update CHANGELOG.md

---
 CHANGELOG.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea32d7b7a..1d17deb88 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,10 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Added
 - `allow_missing` and `allow_extra` keyword arguments to `Objective.transform`
 - `ContinuousCardinalityConstraint` is now compatible with `BotorchRecommender`
+- Attribute `max_n_subspaces` to `BotorchRecommender`, allowing to control
+  optimization behavior in the presence of multiple subspaces
 - Utilities `inactive_parameter_combinations` and`n_inactive_parameter_combinations` 
   in both `ContinuousCardinalityConstraint`and `SubspaceContinuous`
-- Attribute `n_threshold_inactive_parameters_generator` added to `BotorchRecommender`
-- Class `_FixedNumericalContinuousParameter`
 
 ### Deprecations
 - Passing a dataframe via the `data` argument to `Objective.transform` is no longer

From a721f21a60edb78a3e6b2fcccd24e6b1974f8455 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Fri, 1 Nov 2024 11:23:55 +0100
Subject: [PATCH 045/108] Fix tests

---
 .../test_cardinality_constraint_continuous.py | 59 ++++++++--------
 .../test_constraints_continuous.py            | 70 +++++++++++--------
 tests/test_searchspace.py                     |  4 +-
 3 files changed, 70 insertions(+), 63 deletions(-)

diff --git a/tests/constraints/test_cardinality_constraint_continuous.py b/tests/constraints/test_cardinality_constraint_continuous.py
index aaff41ccf..acf512663 100644
--- a/tests/constraints/test_cardinality_constraint_continuous.py
+++ b/tests/constraints/test_cardinality_constraint_continuous.py
@@ -14,45 +14,40 @@
 from baybe.searchspace.core import SearchSpace, SubspaceContinuous
 
 
-def _validate_samples(
-    samples: pd.DataFrame,
-    max_cardinality: int,
+def _validate_cardinality_constrained_batch(
+    batch: pd.DataFrame,
     min_cardinality: int,
+    max_cardinality: int,
     batch_size: int,
     threshold: float = 0.0,
 ):
-    """Validate if cardinality-constrained samples fulfill the necessary conditions.
-
-    Conditions to check:
-    * Cardinality is in requested range
-    * The batch contains right number of samples
-    * The samples are free of duplicates (except all zeros)
+    """Validate that a cardinality-constrained batch fulfills the necessary conditions.
 
     Args:
-        samples: Samples to check
-        max_cardinality: Maximum allowed cardinality
-        min_cardinality: Minimum required cardinality
-        batch_size: Requested batch size
+        batch: Batch to validate.
+        min_cardinality: Minimum required cardinality.
+        max_cardinality: Maximum allowed cardinality.
+        batch_size: Requested batch size.
         threshold: Threshold for checking whether a value is treated as zero.
     """
     # Assert that cardinality constraint is fulfilled
-    if threshold == 0.0:
-        # When threshold is zero, abs(value) > threshold is treated as non-zero.
-        n_nonzero = len(samples.columns) - np.sum(samples.abs().le(threshold), axis=1)
-    else:
-        # When threshold is non-zero, abs(value) >= threshold is treated as non-zero.
-        n_nonzero = np.sum(samples.abs().ge(threshold), axis=1)
-
+    n_nonzero = np.sum(~np.isclose(batch, 0.0, atol=threshold), axis=1)
     assert np.all(n_nonzero >= min_cardinality) and np.all(n_nonzero <= max_cardinality)
 
     # Assert that we obtain as many samples as requested
-    assert samples.shape[0] == batch_size
+    assert batch.shape[0] == batch_size
 
-    # If all rows are duplicates of the first row, they must all come from the case
-    # cardinality = 0 (all rows are zeros)
-    all_zero_rows = (samples == 0).all(axis=1)
-    duplicated_rows = samples.duplicated()
-    assert ~np.all(duplicated_rows[1:]) | np.all(all_zero_rows)
+    # Sanity check: If all recommendations in the batch are identical, something is
+    # fishy – unless the cardinality is 0, in which case the entire batch must contain
+    # zeros. Technically, the probability of getting such a degenerate batch
+    # is not zero, hence this is not a strict requirement. However, in earlier BoTorch
+    # versions, this simply happened due to a bug in their sampler:
+    # https://github.com/pytorch/botorch/issues/2351
+    # We thus include this check as a safety net for catching regressions. If it
+    # turns out the check fails because we observe degenerate batches as actual
+    # recommendations, we need to invent something smarter.
+    if len(unique_row := batch.drop_duplicates()) == 1:
+        assert (unique_row.iloc[0] == 0.0).all() and (max_cardinality == 0)
 
 
 # Combinations of cardinalities to be tested
@@ -88,8 +83,10 @@ def test_sampling_cardinality_constraint(cardinality_bounds: tuple[int, int]):
     subspace = SubspaceContinuous(parameters=parameters, constraints_nonlin=constraints)
     samples = subspace.sample_uniform(BATCH_SIZE)
 
-    # Assert that conditions listed in_validate_samples() are fulfilled
-    _validate_samples(samples, max_cardinality, min_cardinality, BATCH_SIZE)
+    # Assert that the constraint conditions hold
+    _validate_cardinality_constrained_batch(
+        samples, min_cardinality, max_cardinality, BATCH_SIZE
+    )
 
 
 def test_polytope_sampling_with_cardinality_constraint():
@@ -136,9 +133,9 @@ def test_polytope_sampling_with_cardinality_constraint():
 
     samples = searchspace.continuous.sample_uniform(BATCH_SIZE)
 
-    # Assert that conditions listed in_validate_samples() are fulfilled
-    _validate_samples(
-        samples[params_cardinality], MAX_CARDINALITY, MIN_CARDINALITY, BATCH_SIZE
+    # Assert that the constraint conditions hold
+    _validate_cardinality_constrained_batch(
+        samples[params_cardinality], MIN_CARDINALITY, MAX_CARDINALITY, BATCH_SIZE
     )
 
     # Assert that linear equality constraint is fulfilled
diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py
index 065af451c..4463cbec2 100644
--- a/tests/constraints/test_constraints_continuous.py
+++ b/tests/constraints/test_constraints_continuous.py
@@ -1,14 +1,22 @@
 """Test for imposing continuous constraints."""
 
-import sys
-
 import numpy as np
+import pandas as pd
 import pytest
 from pytest import param
 
 from baybe.constraints import ContinuousLinearConstraint
+from baybe.constraints.continuous import ContinuousCardinalityConstraint
+from baybe.parameters.numerical import NumericalContinuousParameter
+from baybe.recommenders.pure.bayesian.base import BayesianRecommender
+from baybe.recommenders.pure.bayesian.botorch import BotorchRecommender
+from baybe.recommenders.pure.nonpredictive.sampling import RandomRecommender
+from baybe.searchspace.core import SearchSpace
+from baybe.targets.numerical import NumericalTarget
 from tests.conftest import run_iterations
-from tests.constraints.test_cardinality_constraint_continuous import _validate_samples
+from tests.constraints.test_cardinality_constraint_continuous import (
+    _validate_cardinality_constrained_batch,
+)
 
 
 @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]])
@@ -71,35 +79,37 @@ 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.slow
-@pytest.mark.parametrize(
-    "parameter_names", [["Conti_finite1", "Conti_finite2", "Conti_finite3"]]
-)
-@pytest.mark.parametrize("constraint_names", [["ContiConstraint_5"]])
-@pytest.mark.parametrize("batch_size", [5], ids=["b5"])
-def test_cardinality_constraint(campaign, n_iterations, batch_size):
-    """Test cardinality constraint for both random recommender and botorch
-    recommender."""  # noqa
+@pytest.mark.parametrize("recommender", [RandomRecommender(), BotorchRecommender()])
+def test_cardinality_constraint(recommender):
+    """Cardinality constraints are taken into account by the recommender."""
+    MIN_CARDINALITY = 4
+    MAX_CARDINALITY = 7
+    BATCH_SIZE = 10
 
-    MIN_CARDINALITY = 1
-    MAX_CARDINALITY = 2
-    run_iterations(campaign, n_iterations, batch_size, add_noise=False)
-    recommendations = campaign.measurements
-
-    print(recommendations)
-
-    # Assert that conditions listed in_validate_samples() are fulfilled
-    for i_batch in range(2):
-        _validate_samples(
-            recommendations.loc[
-                0 + i_batch * batch_size : (i_batch + 1) * batch_size - 1,
-                ["Conti_finite1", "Conti_finite2", "Conti_finite3"],
-            ],
-            max_cardinality=MAX_CARDINALITY,
-            min_cardinality=MIN_CARDINALITY,
-            batch_size=batch_size,
-            threshold=sys.float_info.min,
+    parameters = [NumericalContinuousParameter(str(i), (0, 1)) for i in range(10)]
+    constraints = [
+        ContinuousCardinalityConstraint(
+            [p.name for p in parameters], MIN_CARDINALITY, MAX_CARDINALITY
         )
+    ]
+    searchspace = SearchSpace.from_product(parameters, constraints)
+
+    if isinstance(recommender, BayesianRecommender):
+        objective = NumericalTarget("t", "MAX").to_objective()
+        measurements = pd.DataFrame(searchspace.continuous.sample_uniform(2))
+        measurements["t"] = np.random.random(len(measurements))
+    else:
+        objective = None
+        measurements = None
+
+    recommendation = recommender.recommend(
+        BATCH_SIZE, searchspace, objective, measurements
+    )
+
+    # Assert that the constraint conditions hold
+    _validate_cardinality_constrained_batch(
+        recommendation, MIN_CARDINALITY, MAX_CARDINALITY, BATCH_SIZE
+    )
 
 
 @pytest.mark.slow
diff --git a/tests/test_searchspace.py b/tests/test_searchspace.py
index 6356968a1..68bb5dd29 100644
--- a/tests/test_searchspace.py
+++ b/tests/test_searchspace.py
@@ -271,8 +271,8 @@ def test_cardinality_constraints_with_overlapping_parameters():
 
 
 def test_cardinality_constraint_with_invalid_parameter_bounds():
-    """Impose a cardinality constraint on a parameter whose valid area does not
-    include zero raises an error."""  # noqa
+    """Imposing a cardinality constraint on a parameter whose range does not include
+    zero raises an error."""  # noqa
     parameters = (
         NumericalContinuousParameter("c1", (0, 1)),
         NumericalContinuousParameter("c2", (1, 2)),

From e84dda902ce4f11dbb93ac9cff9b624eaddf92fa Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Fri, 1 Nov 2024 12:18:46 +0100
Subject: [PATCH 046/108] Explain mechanism of recommending with cardinality
 constraints

---
 baybe/recommenders/pure/bayesian/botorch.py | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 68f4b2289..56df659ca 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -232,6 +232,14 @@ def _recommend_continuous_with_cardinality_constraints(
     ) -> tuple[Tensor, Tensor]:
         """Recommend from a continuous search space with cardinality constraints.
 
+        This is achieved by considering the individual restricted subspaces that can be
+        obtained by splitting the parameters into sets of active and inactive
+        parameters, according to what is allowed by the cardinality constraints. In each
+        of these spaces, the in-/activity assignment is fixed, so that the cardinality
+        constraints can be removed and a regular optimization can be performed. The
+        recommendation is then constructed from the combined optimization results of the
+        unconstrained spaces.
+
         Args:
             subspace_continuous: The continuous subspace from which to generate
                 recommendations.

From fa1326763e48bed5993f8093a23b0a623e1e96ad Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Fri, 13 Dec 2024 11:51:38 +0100
Subject: [PATCH 047/108] Add near-zero threshold to continuous numerical
 parameter

---
 baybe/parameters/numerical.py   | 18 ++++++++++++++++++
 baybe/parameters/utils.py       | 16 ++++++++--------
 baybe/searchspace/continuous.py |  2 +-
 3 files changed, 27 insertions(+), 9 deletions(-)

diff --git a/baybe/parameters/numerical.py b/baybe/parameters/numerical.py
index 439a80050..6f4158f8d 100644
--- a/baybe/parameters/numerical.py
+++ b/baybe/parameters/numerical.py
@@ -112,6 +112,9 @@ class NumericalContinuousParameter(ContinuousParameter):
     bounds: Interval = field(default=None, converter=convert_bounds)
     """The bounds of the parameter."""
 
+    near_zero_threshold: float = field(default=1e-8, converter=float)
+    """A threshold for determining if the value is considered near-zero."""
+
     @bounds.validator
     def _validate_bounds(self, _: Any, value: Interval) -> None:  # noqa: DOC101, DOC103
         """Validate bounds.
@@ -149,6 +152,21 @@ def summary(self) -> dict:
         )
         return param_dict
 
+    def is_near_zero(self, item: float) -> bool:
+        """Return whether an item is near-zero.
+
+        Important:
+            Value in the open interval (-near_zero_threshold, near_zero_threshold)
+            will be treated as near_zero.
+
+        Args:
+            item: The value to be checked.
+
+        Returns:
+            ``True`` if the value is near-zero, ``False`` otherwise.
+        """
+        return abs(item) < self.near_zero_threshold
+
 
 @define(frozen=True, slots=False)
 class _FixedNumericalContinuousParameter(ContinuousParameter):
diff --git a/baybe/parameters/utils.py b/baybe/parameters/utils.py
index 31d4f1d43..c29a71523 100644
--- a/baybe/parameters/utils.py
+++ b/baybe/parameters/utils.py
@@ -92,7 +92,7 @@ def sort_parameters(parameters: Collection[Parameter]) -> tuple[Parameter, ...]:
 
 
 def activate_parameter(
-    parameter: NumericalContinuousParameter, threshold: float
+    parameter: NumericalContinuousParameter,
 ) -> NumericalContinuousParameter:
     """Activates a given parameter by moving its bounds away from zero.
 
@@ -104,7 +104,6 @@ def activate_parameter(
 
     Args:
         parameter: The parameter to be activated.
-        threshold: The threshold for a parameter to be considered active.
 
     Returns:
         A copy of the parameter with adjusted bounds.
@@ -117,22 +116,23 @@ def activate_parameter(
     upper = parameter.bounds.upper
 
     def in_inactive_range(x: float, /) -> bool:
-        return -threshold <= x <= threshold
+        return -parameter.near_zero_threshold <= x <= parameter.near_zero_threshold
 
     # Upper bound is in inactive range
-    if lower < -threshold and in_inactive_range(upper):
-        return evolve(parameter, bounds=(lower, -threshold))
+    if lower < -parameter.near_zero_threshold and in_inactive_range(upper):
+        return evolve(parameter, bounds=(lower, -parameter.near_zero_threshold))
 
     # Lower bound is in inactive range
-    if upper > threshold and in_inactive_range(lower):
-        return evolve(parameter, bounds=(threshold, upper))
+    if upper > parameter.near_zero_threshold and in_inactive_range(lower):
+        return evolve(parameter, bounds=(parameter.near_zero_threshold, upper))
 
     # Both bounds in inactive range
     if in_inactive_range(lower) and in_inactive_range(upper):
         raise ValueError(
             f"Parameter '{parameter.name}' cannot be set active since its "
             f"bounds {parameter.bounds.to_tuple()} are entirely contained in the "
-            f"inactive range [-{threshold}, {threshold}]."
+            f"inactive range [-{parameter.near_zero_threshold},"
+            f" {parameter.near_zero_threshold}]."
         )
 
     # Both bounds separated from inactive range
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 15398dc58..9f1784e2b 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -366,7 +366,7 @@ def _enforce_cardinality_constraints_via_assignment(
             if p.name in inactive_parameter_names:
                 p_adjusted = _FixedNumericalContinuousParameter(name=p.name, value=0.0)
             elif p.name in active_parameter_names:
-                p_adjusted = activate_parameter(p, threshold)
+                p_adjusted = activate_parameter(p)
             else:
                 p_adjusted = p
             adjusted_parameters.append(p_adjusted)

From 7aa7c3fec2d09f01bc01fcb98ec4e2ffb8d921bf Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Fri, 13 Dec 2024 11:57:48 +0100
Subject: [PATCH 048/108] Refine activate parameter helper function

- Replace redandunt function
- Ensure near-zero range being an open interval
---
 baybe/parameters/utils.py | 13 +++++--------
 1 file changed, 5 insertions(+), 8 deletions(-)

diff --git a/baybe/parameters/utils.py b/baybe/parameters/utils.py
index c29a71523..108a8b56c 100644
--- a/baybe/parameters/utils.py
+++ b/baybe/parameters/utils.py
@@ -115,19 +115,16 @@ def activate_parameter(
     lower = parameter.bounds.lower
     upper = parameter.bounds.upper
 
-    def in_inactive_range(x: float, /) -> bool:
-        return -parameter.near_zero_threshold <= x <= parameter.near_zero_threshold
-
-    # Upper bound is in inactive range
-    if lower < -parameter.near_zero_threshold and in_inactive_range(upper):
+    # Upper bound is in near-zero range
+    if lower <= -parameter.near_zero_threshold and parameter.is_near_zero(upper):
         return evolve(parameter, bounds=(lower, -parameter.near_zero_threshold))
 
-    # Lower bound is in inactive range
-    if upper > parameter.near_zero_threshold and in_inactive_range(lower):
+    # Lower bound is in near-zero range
+    if upper > parameter.near_zero_threshold and parameter.is_near_zero(lower):
         return evolve(parameter, bounds=(parameter.near_zero_threshold, upper))
 
     # Both bounds in inactive range
-    if in_inactive_range(lower) and in_inactive_range(upper):
+    if parameter.is_near_zero(lower) and parameter.is_near_zero(upper):
         raise ValueError(
             f"Parameter '{parameter.name}' cannot be set active since its "
             f"bounds {parameter.bounds.to_tuple()} are entirely contained in the "

From cfdf1e32d40a1e27796aac90802f40e0e970bdc0 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Sat, 14 Dec 2024 16:51:13 +0100
Subject: [PATCH 049/108] Show warnings when any minimum cardinality
 constraints are violated.

---
 baybe/exceptions.py                         |  4 ++
 baybe/recommenders/pure/bayesian/botorch.py | 17 ++++-
 baybe/utils/cardinality_constraints.py      | 76 +++++++++++++++++++++
 3 files changed, 96 insertions(+), 1 deletion(-)
 create mode 100644 baybe/utils/cardinality_constraints.py

diff --git a/baybe/exceptions.py b/baybe/exceptions.py
index 661f61a97..3fd5aaf33 100644
--- a/baybe/exceptions.py
+++ b/baybe/exceptions.py
@@ -9,6 +9,10 @@ class UnusedObjectWarning(UserWarning):
     """
 
 
+class MinimumCardinalityViolatedWarning(UserWarning):
+    """Minimum cardinality constraints are violated."""
+
+
 ##### Exceptions #####
 
 
diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 56df659ca..1bdbb5bb4 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -4,6 +4,7 @@
 
 import gc
 import math
+import warnings
 from collections.abc import Collection, Iterable
 from typing import TYPE_CHECKING, Any, ClassVar
 
@@ -19,6 +20,7 @@
 from baybe.exceptions import (
     IncompatibilityError,
     IncompatibleAcquisitionFunctionError,
+    MinimumCardinalityViolatedWarning,
 )
 from baybe.parameters.numerical import _FixedNumericalContinuousParameter
 from baybe.recommenders.pure.bayesian.base import BayesianRecommender
@@ -28,6 +30,7 @@
     SubspaceContinuous,
     SubspaceDiscrete,
 )
+from baybe.utils.cardinality_constraints import is_min_cardinality_fulfilled
 from baybe.utils.dataframe import to_tensor
 from baybe.utils.plotting import to_string
 from baybe.utils.sampling_algorithms import (
@@ -283,7 +286,19 @@ def _recommend_continuous_with_cardinality_constraints(
             for inactive_parameters in iterator
         )
 
-        return self._optimize_continuous_subspaces(subspaces, batch_size)
+        points, acqf_value = self._optimize_continuous_subspaces(subspaces, batch_size)
+
+        # Check if any minimum cardinality constraints are violated
+        if not is_min_cardinality_fulfilled(
+            subspace_continuous,
+            pd.DataFrame(points, columns=subspace_continuous.parameter_names),
+        ):
+            warnings.warn(
+                "Minimum cardinality constraints are not guaranteed.",
+                MinimumCardinalityViolatedWarning,
+            )
+
+        return points, acqf_value
 
     def _recommend_continuous_without_cardinality_constraints(
         self,
diff --git a/baybe/utils/cardinality_constraints.py b/baybe/utils/cardinality_constraints.py
new file mode 100644
index 000000000..92cd8ede6
--- /dev/null
+++ b/baybe/utils/cardinality_constraints.py
@@ -0,0 +1,76 @@
+"""Utilities related to cardinality constraints."""
+
+import numpy as np
+import pandas as pd
+
+from baybe.parameters import NumericalContinuousParameter
+from baybe.searchspace import SubspaceContinuous
+
+
+def count_near_zeros(
+    parameters: tuple[NumericalContinuousParameter, ...], points: pd.DataFrame
+) -> np.ndarray:
+    """Return the counts of near-zeros in the recommendations.
+
+    Args:
+        parameters: A list of parameter objects according to which the counts of
+            near-zeros in the recommendations should be calculated.
+        points: The recommendations of the parameter objects.
+
+    Raises:
+        ValueError: If the dimensionality of parameters does not match that of points.
+
+    Returns:
+        The counts of near-zero values in the recommendations.
+
+
+    """
+    if len(parameters) != points.shape[1]:
+        raise ValueError(
+            "Dimensionality mismatch: number of parameters = {len("
+            "parameters)}, parameters in recommendations "
+            "= {points.shape[1]}."
+        )
+
+    # Boolean values indicating whether candidate is near-zero: True for near-zero.
+    p_thresholds = np.array([p.near_zero_threshold for p in parameters])
+    p_thresholds_mask = np.broadcast_to(p_thresholds, points.shape)
+    near_zero_flags = (points > -p_thresholds_mask) & (points < p_thresholds_mask)
+    return np.sum(near_zero_flags, axis=1)
+
+
+def is_min_cardinality_fulfilled(
+    subspace_continuous: SubspaceContinuous, batch: pd.DataFrame
+) -> bool:
+    """Check whether any minimum cardinality constraints are fulfilled.
+
+    Args:
+        subspace_continuous: The continuous subspace from which candidates are
+            generated.
+        batch: The recommended batch
+
+    Returns:
+        Return "True" if all minimum cardinality constraints are fulfilled; "False"
+        otherwise.
+    """
+    if len(subspace_continuous.constraints_cardinality) == 0:
+        return True
+
+    for c in subspace_continuous.constraints_cardinality:
+        if c.min_cardinality == 0:
+            continue
+
+        # TODO: Is the parameters in constraints sorted or not? Can we assume the
+        #  order of parameters in constraints align with that in the subspace?
+
+        # Counts the near-zero elements
+        batch_related_to_c = batch[c.parameters]
+        parameters_related_to_c = tuple(
+            p for p in subspace_continuous.parameters if p.name in c.parameters
+        )
+        n_near_zeros = count_near_zeros(parameters_related_to_c, batch_related_to_c)
+
+        # When the minimum cardinality is violated
+        if np.any(len(c.parameters) - n_near_zeros < c.min_cardinality):
+            return False
+    return True

From e3c6620962e2f6a01962fda3003411641abbbff6 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Sun, 15 Dec 2024 19:55:42 +0100
Subject: [PATCH 050/108] Update test related to cardinality constraints

---
 .../test_cardinality_constraint_continuous.py | 39 ++++++++++++++-----
 .../test_constraints_continuous.py            | 16 ++++++--
 2 files changed, 42 insertions(+), 13 deletions(-)

diff --git a/tests/constraints/test_cardinality_constraint_continuous.py b/tests/constraints/test_cardinality_constraint_continuous.py
index acf512663..a685d9c31 100644
--- a/tests/constraints/test_cardinality_constraint_continuous.py
+++ b/tests/constraints/test_cardinality_constraint_continuous.py
@@ -1,6 +1,8 @@
 """Tests for the continuous cardinality constraint."""
 
+import warnings
 from itertools import combinations_with_replacement
+from warnings import WarningMessage
 
 import numpy as np
 import pandas as pd
@@ -12,6 +14,7 @@
 )
 from baybe.parameters.numerical import NumericalContinuousParameter
 from baybe.searchspace.core import SearchSpace, SubspaceContinuous
+from baybe.utils.cardinality_constraints import count_near_zeros
 
 
 def _validate_cardinality_constrained_batch(
@@ -19,7 +22,8 @@ def _validate_cardinality_constrained_batch(
     min_cardinality: int,
     max_cardinality: int,
     batch_size: int,
-    threshold: float = 0.0,
+    parameters: tuple[NumericalContinuousParameter],
+    captured_warnings: list[WarningMessage | None],
 ):
     """Validate that a cardinality-constrained batch fulfills the necessary conditions.
 
@@ -28,11 +32,20 @@ def _validate_cardinality_constrained_batch(
         min_cardinality: Minimum required cardinality.
         max_cardinality: Maximum allowed cardinality.
         batch_size: Requested batch size.
-        threshold: Threshold for checking whether a value is treated as zero.
+        parameters: A list of parameters for which recommendations are provided.
+        captured_warnings: A list of captured warnings.
     """
-    # Assert that cardinality constraint is fulfilled
-    n_nonzero = np.sum(~np.isclose(batch, 0.0, atol=threshold), axis=1)
-    assert np.all(n_nonzero >= min_cardinality) and np.all(n_nonzero <= max_cardinality)
+    # Assert that the maximum cardinality constraint is fulfilled
+    n_nonzeros = len(parameters) - count_near_zeros(parameters, batch)
+    assert np.all(n_nonzeros <= max_cardinality)
+
+    # Check whether the minimum cardinality constraint is fulfilled
+    is_min_cardinality_fulfilled = np.all(n_nonzeros >= min_cardinality)
+
+    # A warning must be raised when the minimum cardinality constraint is not fulfilled
+    if not is_min_cardinality_fulfilled:
+        w_message = "Minimum cardinality constraints are not guaranteed."
+        assert any(str(w.message) == w_message for w in captured_warnings)
 
     # Assert that we obtain as many samples as requested
     assert batch.shape[0] == batch_size
@@ -81,11 +94,13 @@ def test_sampling_cardinality_constraint(cardinality_bounds: tuple[int, int]):
     )
 
     subspace = SubspaceContinuous(parameters=parameters, constraints_nonlin=constraints)
-    samples = subspace.sample_uniform(BATCH_SIZE)
+
+    with warnings.catch_warnings(record=True) as w:
+        samples = subspace.sample_uniform(BATCH_SIZE)
 
     # Assert that the constraint conditions hold
     _validate_cardinality_constrained_batch(
-        samples, min_cardinality, max_cardinality, BATCH_SIZE
+        samples, min_cardinality, max_cardinality, BATCH_SIZE, parameters, w
     )
 
 
@@ -131,11 +146,17 @@ def test_polytope_sampling_with_cardinality_constraint():
     ]
     searchspace = SearchSpace.from_product(parameters, constraints)
 
-    samples = searchspace.continuous.sample_uniform(BATCH_SIZE)
+    with warnings.catch_warnings(record=True) as w:
+        samples = searchspace.continuous.sample_uniform(BATCH_SIZE)
 
     # Assert that the constraint conditions hold
     _validate_cardinality_constrained_batch(
-        samples[params_cardinality], MIN_CARDINALITY, MAX_CARDINALITY, BATCH_SIZE
+        samples[params_cardinality],
+        MIN_CARDINALITY,
+        MAX_CARDINALITY,
+        BATCH_SIZE,
+        tuple(p for p in parameters if p.name in params_cardinality),
+        w,
     )
 
     # Assert that linear equality constraint is fulfilled
diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py
index 4463cbec2..e326ebb83 100644
--- a/tests/constraints/test_constraints_continuous.py
+++ b/tests/constraints/test_constraints_continuous.py
@@ -1,5 +1,7 @@
 """Test for imposing continuous constraints."""
 
+import warnings
+
 import numpy as np
 import pandas as pd
 import pytest
@@ -102,13 +104,19 @@ def test_cardinality_constraint(recommender):
         objective = None
         measurements = None
 
-    recommendation = recommender.recommend(
-        BATCH_SIZE, searchspace, objective, measurements
-    )
+    with warnings.catch_warnings(record=True) as w:
+        recommendation = recommender.recommend(
+            BATCH_SIZE, searchspace, objective, measurements
+        )
 
     # Assert that the constraint conditions hold
     _validate_cardinality_constrained_batch(
-        recommendation, MIN_CARDINALITY, MAX_CARDINALITY, BATCH_SIZE
+        recommendation,
+        MIN_CARDINALITY,
+        MAX_CARDINALITY,
+        BATCH_SIZE,
+        tuple(parameters),
+        w,
     )
 
 

From b85924fbc2372baeac4868eafae7c401bd2914fb Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Sun, 15 Dec 2024 20:21:22 +0100
Subject: [PATCH 051/108] Add to-dos - Add to-do related to customized error in
 botorch - Add to-do related to active parameters guarantee in random sampler

---
 baybe/searchspace/continuous.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 9f1784e2b..139440ea9 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -517,11 +517,14 @@ def _sample_from_polytope_with_cardinality_constraints(
             # Randomly set some parameters inactive
             inactive_params_sample = self._sample_inactive_parameters(1)[0]
 
+            # TODO: active parameters must be guaranteed non-zero!
             # Remove the inactive parameters from the search space
             subspace_without_cardinality_constraint = self._drop_parameters(
                 inactive_params_sample
             )
 
+            # TODO: Replace ValueError with customized erorr. See
+            #  https://github.com/pytorch/botorch/pull/2652
             # Sample from the reduced space
             try:
                 sample = subspace_without_cardinality_constraint.sample_uniform(1)

From 22b19f9698e068d5e61aa53155232cb82a65083a Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Mon, 16 Dec 2024 09:53:44 +0100
Subject: [PATCH 052/108] Add test on catching warning related to violation of
 minimum cardinality constraint

---
 baybe/parameters/numerical.py                 |  2 +-
 .../test_cardinality_constraint_continuous.py | 63 +++++++++++++++++++
 2 files changed, 64 insertions(+), 1 deletion(-)

diff --git a/baybe/parameters/numerical.py b/baybe/parameters/numerical.py
index 6f4158f8d..4c087199e 100644
--- a/baybe/parameters/numerical.py
+++ b/baybe/parameters/numerical.py
@@ -112,7 +112,7 @@ class NumericalContinuousParameter(ContinuousParameter):
     bounds: Interval = field(default=None, converter=convert_bounds)
     """The bounds of the parameter."""
 
-    near_zero_threshold: float = field(default=1e-8, converter=float)
+    near_zero_threshold: float = field(default=1e-5, converter=float)
     """A threshold for determining if the value is considered near-zero."""
 
     @bounds.validator
diff --git a/tests/constraints/test_cardinality_constraint_continuous.py b/tests/constraints/test_cardinality_constraint_continuous.py
index a685d9c31..a2d804acb 100644
--- a/tests/constraints/test_cardinality_constraint_continuous.py
+++ b/tests/constraints/test_cardinality_constraint_continuous.py
@@ -12,8 +12,11 @@
     ContinuousCardinalityConstraint,
     ContinuousLinearConstraint,
 )
+from baybe.exceptions import MinimumCardinalityViolatedWarning
 from baybe.parameters.numerical import NumericalContinuousParameter
+from baybe.recommenders import BotorchRecommender
 from baybe.searchspace.core import SearchSpace, SubspaceContinuous
+from baybe.targets.numerical import NumericalTarget
 from baybe.utils.cardinality_constraints import count_near_zeros
 
 
@@ -172,3 +175,63 @@ def test_polytope_sampling_with_cardinality_constraint():
         .ge(rhs_inequality - TOLERANCE)
         .all()
     )
+
+
+def test_min_cardinality_warning():
+    """Providing candidates violating minimum cardinality constraint raises a
+    warning.
+    """  # noqa
+    N_PARAMETERS = 2
+    MIN_CARDINALITY = 2
+    MAX_CARDINALITY = 2
+    BATCH_SIZE = 20
+
+    lower_bound = -0.5
+    upper_bound = 0.5
+    stepsize = 0.05
+    parameters = [
+        NumericalContinuousParameter(name=f"x_{i+1}", bounds=(lower_bound, upper_bound))
+        for i in range(N_PARAMETERS)
+    ]
+
+    constraints = [
+        ContinuousCardinalityConstraint(
+            parameters=[p.name for p in parameters],
+            max_cardinality=MAX_CARDINALITY,
+            min_cardinality=MIN_CARDINALITY,
+        ),
+    ]
+
+    searchspace = SearchSpace.from_product(parameters, constraints)
+    objective = NumericalTarget("t", "MAX").to_objective()
+
+    # Create a scenario in which
+    # - The optimum of the target function is at the origin
+    # - The Botorch recommender is likely to provide candidates at the origin,
+    # which violates the minimum cardinality constraint.
+    def custom_target(x1: np.ndarray, x2: np.ndarray) -> np.ndarray:
+        """A custom target function with maximum at the origin."""
+        return -abs(x1) - abs(x2)
+
+    def prepare_measurements() -> pd.DataFrame:
+        """Prepare measurements."""
+        x1 = np.arange(lower_bound, upper_bound + stepsize, stepsize)
+        # Exclude 0 from the array
+        X1, X2 = np.meshgrid(x1[abs(x1) > stepsize / 2], x1[abs(x1) > stepsize / 2])
+
+        return pd.DataFrame(
+            {
+                "x_1": X1.flatten(),
+                "x_2": X2.flatten(),
+                "t": custom_target(X1.flatten(), X2.flatten()),
+            }
+        )
+
+    with warnings.catch_warnings(record=True) as captured_warnings:
+        BotorchRecommender().recommend(
+            BATCH_SIZE, searchspace, objective, prepare_measurements()
+        )
+        assert any(
+            issubclass(w.category, MinimumCardinalityViolatedWarning)
+            for w in captured_warnings
+        )

From b0dc037fb42bf79fdae01ba437a54c1f0eb5a0b0 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Mon, 16 Dec 2024 10:54:11 +0100
Subject: [PATCH 053/108] Update CHANGELOG.md

---
 CHANGELOG.md | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d17deb88..91e84cb95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   optimization behavior in the presence of multiple subspaces
 - Utilities `inactive_parameter_combinations` and`n_inactive_parameter_combinations` 
   in both `ContinuousCardinalityConstraint`and `SubspaceContinuous`
+- Attribute `near_zero_threshold` and utility `is_near_zero` to 
+  `NumericalContinuousParameter`
+- Warning `MinimumCardinalityViolatedWarning` is triggered when any minimum 
+  cardinality is violated in `BotorchRecommender`
+- Utilities `count_near_zeros` and `is_min_cardinality_fulfilled`
 
 ### Deprecations
 - Passing a dataframe via the `data` argument to `Objective.transform` is no longer

From 35825b84f4268257c9d865141b36f4dedc4fe114 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Mon, 16 Dec 2024 11:38:13 +0100
Subject: [PATCH 054/108] Clean up merge conflict code

---
 CHANGELOG.md | 1 -
 1 file changed, 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24bf72211..ae87bf664 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,7 +45,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   `NumericalTarget` now raises an error
 - Crash when using `ContinuousCardinalityConstraint` caused by an unintended interplay
   between constraints and dropped parameters yielding empty parameter sets
->>>>>>> main
 
 ### Deprecations
 - Passing a dataframe via the `data` argument to `Objective.transform` is no longer

From 3e275e4454556d6ae42d21cd77e0035e5613a5cc Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Mon, 16 Dec 2024 14:03:21 +0100
Subject: [PATCH 055/108] Refine logic in counting the near-zero elements

---
 baybe/utils/cardinality_constraints.py | 29 ++++++++++++++------------
 1 file changed, 16 insertions(+), 13 deletions(-)

diff --git a/baybe/utils/cardinality_constraints.py b/baybe/utils/cardinality_constraints.py
index 92cd8ede6..5c2b178cc 100644
--- a/baybe/utils/cardinality_constraints.py
+++ b/baybe/utils/cardinality_constraints.py
@@ -18,22 +18,29 @@ def count_near_zeros(
         points: The recommendations of the parameter objects.
 
     Raises:
-        ValueError: If the dimensionality of parameters does not match that of points.
+        ValueError: If parameters does not cover all parameters present in points.
 
     Returns:
         The counts of near-zero values in the recommendations.
 
 
     """
-    if len(parameters) != points.shape[1]:
+    p_names = [p.name for p in parameters]
+    if not set(points.columns).issubset(set(p_names)):
         raise ValueError(
-            "Dimensionality mismatch: number of parameters = {len("
-            "parameters)}, parameters in recommendations "
-            "= {points.shape[1]}."
+            "Parameters must cover all parameters present in points: "
+            "parameter names in parameters are: {p_name} and parameter "
+            "names from points are: {points.columns}."
         )
 
-    # Boolean values indicating whether candidate is near-zero: True for near-zero.
-    p_thresholds = np.array([p.near_zero_threshold for p in parameters])
+    # Only keep parameters that are present in points; The order of parameters
+    # aligns with that in points.
+    parameters_filtered_sorted = (
+        p for p_name in points.columns for p in parameters if p.name == p_name
+    )
+
+    # Boolean values indicating whether the candidate is near-zero: True for near-zero.
+    p_thresholds = np.array([p.near_zero_threshold for p in parameters_filtered_sorted])
     p_thresholds_mask = np.broadcast_to(p_thresholds, points.shape)
     near_zero_flags = (points > -p_thresholds_mask) & (points < p_thresholds_mask)
     return np.sum(near_zero_flags, axis=1)
@@ -60,15 +67,11 @@ def is_min_cardinality_fulfilled(
         if c.min_cardinality == 0:
             continue
 
-        # TODO: Is the parameters in constraints sorted or not? Can we assume the
-        #  order of parameters in constraints align with that in the subspace?
-
         # Counts the near-zero elements
         batch_related_to_c = batch[c.parameters]
-        parameters_related_to_c = tuple(
-            p for p in subspace_continuous.parameters if p.name in c.parameters
+        n_near_zeros = count_near_zeros(
+            subspace_continuous.parameters, batch_related_to_c
         )
-        n_near_zeros = count_near_zeros(parameters_related_to_c, batch_related_to_c)
 
         # When the minimum cardinality is violated
         if np.any(len(c.parameters) - n_near_zeros < c.min_cardinality):

From 62f0ed62c3224998d28992947eff95f63072555b Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Wed, 8 Jan 2025 10:28:59 +0100
Subject: [PATCH 056/108] Add TODO related to customized infeasibility error in
 botorch

---
 baybe/recommenders/pure/bayesian/botorch.py | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 1bdbb5bb4..356aae00f 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -476,6 +476,11 @@ def _optimize_continuous_subspaces(
     ) -> tuple[Tensor, Tensor]:
         """Find the optimum candidates from multiple continuous subspaces.
 
+        **Important**: A subspace without a feasible solution will be ignored
+        silently, and no warning will be raised. This design is intentional to
+        accommodate recommendations with cardinality constraints. Please be mindful
+        of this behavior when invoking this method.
+
         Args:
             subspaces: The subspaces to consider for the optimization.
             batch_size: The number of points to be recommended.
@@ -495,6 +500,8 @@ def _optimize_continuous_subspaces(
                 points_all.append(p)
                 acqf_values_all.append(acqf)
 
+            # TODO: Replace ValueError with customized erorr. See
+            #  https://github.com/pytorch/botorch/pull/2652
             # The optimization problem may be infeasible in certain subspaces
             except ValueError:
                 pass

From 9af846b2734522e79bf3a4925175aba0fd251a75 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Wed, 8 Jan 2025 11:32:54 +0100
Subject: [PATCH 057/108] Add threshold to continuous cardinality constraint

---
 baybe/constraints/continuous.py | 45 ++++++++++++++++++++++++++++++++-
 1 file changed, 44 insertions(+), 1 deletion(-)

diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py
index fbcaa18e9..4675ac9d1 100644
--- a/baybe/constraints/continuous.py
+++ b/baybe/constraints/continuous.py
@@ -10,7 +10,7 @@
 from typing import TYPE_CHECKING, Any
 
 import numpy as np
-from attr.validators import in_
+from attr.validators import gt, in_, lt
 from attrs import define, field
 
 from baybe.constraints.base import (
@@ -19,6 +19,7 @@
     ContinuousNonlinearConstraint,
 )
 from baybe.parameters import NumericalContinuousParameter
+from baybe.utils.interval import Interval
 from baybe.utils.numerical import DTypeFloatNumpy
 from baybe.utils.validation import finite_float
 
@@ -140,6 +141,11 @@ class ContinuousCardinalityConstraint(
 ):
     """Class for continuous cardinality constraints."""
 
+    relative_threshold: float = field(
+        default=1e-2, converter=float, validator=[gt(0.0), lt(1.0)]
+    )
+    """A relative threshold for determining if the value is considered zero."""
+
     @property
     def n_inactive_parameter_combinations(self) -> int:
         """The number of possible inactive parameter combinations."""
@@ -198,6 +204,43 @@ def sample_inactive_parameters(self, batch_size: int = 1) -> list[set[str]]:
 
         return inactive_params
 
+    def get_threshold(self, parameter: NumericalContinuousParameter) -> Interval:
+        """Get the threshold values of a parameter.
+
+        This method calculates the thresholds based on the parameter's bounds
+        and the relative threshold.
+
+        Note:
+            Thresholds (lower, upper) are defined below:
+            * If lower < 0 and upper > 0, any value v with lower < v < upper are treated
+            zero;
+            * If lower = 0 and upper > 0, any value v with lower <= v < upper are
+            treated zero;
+            * If lower < 0 and upper = 0, any value v with lower < v <= upper are
+            treated zero.
+
+
+        Args:
+            parameter: The parameter object.
+
+        Returns:
+            The lower and upper thresholds.
+
+        Raises:
+            ValueError: when parameter_name is not present in parameter list of this
+                constraint.
+        """
+        if parameter.name not in self.parameters:
+            raise ValueError(
+                f"The given parameter with name: {parameter.name} cannot "
+                f"be found in the parameter list: {self.parameters}."
+            )
+
+        return Interval(
+            lower=self.relative_threshold * parameter.bounds.lower,
+            upper=self.relative_threshold * parameter.bounds.upper,
+        )
+
 
 # Collect leftover original slotted classes processed by `attrs.define`
 gc.collect()

From 10e08124469d6b61e174499f58b839df5234f801 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Wed, 8 Jan 2025 15:54:23 +0100
Subject: [PATCH 058/108] Adapt activate_parameter towards threshold per
 cardinality constraints

---
 baybe/parameters/utils.py       | 55 ++++++++++++++++++++++++---------
 baybe/searchspace/continuous.py | 10 ++++--
 2 files changed, 48 insertions(+), 17 deletions(-)

diff --git a/baybe/parameters/utils.py b/baybe/parameters/utils.py
index 108a8b56c..0acb65896 100644
--- a/baybe/parameters/utils.py
+++ b/baybe/parameters/utils.py
@@ -3,11 +3,17 @@
 from collections.abc import Callable, Collection
 from typing import Any, TypeVar
 
+import numpy as np
 import pandas as pd
 from attrs import evolve
 
 from baybe.parameters.base import Parameter
 from baybe.parameters.numerical import NumericalContinuousParameter
+from baybe.utils.interval import Interval
+
+# TODO: Check whether it has been defined in BayBE?
+SMALLEST_FLOAT32 = np.finfo(np.float32).tiny
+"""The smallest 32 bit float number."""
 
 _TParameter = TypeVar("_TParameter", bound=Parameter)
 
@@ -93,6 +99,7 @@ def sort_parameters(parameters: Collection[Parameter]) -> tuple[Parameter, ...]:
 
 def activate_parameter(
     parameter: NumericalContinuousParameter,
+    thresholds: Interval,
 ) -> NumericalContinuousParameter:
     """Activates a given parameter by moving its bounds away from zero.
 
@@ -104,33 +111,53 @@ def activate_parameter(
 
     Args:
         parameter: The parameter to be activated.
+        thresholds: The thresholds of the inactive region of the parameter.
 
     Returns:
         A copy of the parameter with adjusted bounds.
 
     Raises:
+        ValueError: If the threshold does not cover zero.
         ValueError: If the parameter cannot be activated since both its bounds are
             in the inactive range.
     """
-    lower = parameter.bounds.lower
-    upper = parameter.bounds.upper
-
-    # Upper bound is in near-zero range
-    if lower <= -parameter.near_zero_threshold and parameter.is_near_zero(upper):
-        return evolve(parameter, bounds=(lower, -parameter.near_zero_threshold))
-
-    # Lower bound is in near-zero range
-    if upper > parameter.near_zero_threshold and parameter.is_near_zero(lower):
-        return evolve(parameter, bounds=(parameter.near_zero_threshold, upper))
+    lower_bound = parameter.bounds.lower
+    upper_bound = parameter.bounds.upper
+
+    if not thresholds.contains(0.0):
+        raise ValueError("The thresholds must cover zero.")
+
+    # When the lower/upper threshold is zero, it is slightly adjusted to and used as
+    # thresholf for checking the inactive range.
+    # Check ContinuousCardinalityConstraint.get_threshold(parameter) for the definition
+    # of threshold of inactive (near-zero) region.
+    lower_threshold_for_inactive_range = min(thresholds.lower, -SMALLEST_FLOAT32)
+    upper_threshold_for_inactive_range = max(thresholds.upper, SMALLEST_FLOAT32)
+
+    def in_inactive_range(x: float) -> bool:
+        """Return true when x is within the inactive range."""
+        return (
+            lower_threshold_for_inactive_range < x < upper_threshold_for_inactive_range
+        )
 
-    # Both bounds in inactive range
-    if parameter.is_near_zero(lower) and parameter.is_near_zero(upper):
+    # When both bounds in inactive range
+    if in_inactive_range(lower_bound) and in_inactive_range(upper_bound):
         raise ValueError(
             f"Parameter '{parameter.name}' cannot be set active since its "
             f"bounds {parameter.bounds.to_tuple()} are entirely contained in the "
-            f"inactive range [-{parameter.near_zero_threshold},"
-            f" {parameter.near_zero_threshold}]."
+            f"inactive range [-{lower_threshold_for_inactive_range},"
+            f" {upper_threshold_for_inactive_range}]."
         )
 
+    # When the upper bound is in near-zero range, reduce it to the lower threshold of
+    # inactive region.
+    if lower_bound <= thresholds.lower and in_inactive_range(upper_bound):
+        return evolve(parameter, bounds=(lower_bound, thresholds.lower))
+
+    # When the lower bound is in near-zero range, uplift it to the upper threshold of
+    # the inactive region
+    if upper_bound >= thresholds.upper and in_inactive_range(lower_bound):
+        return evolve(parameter, bounds=(thresholds.upper, upper_bound))
+
     # Both bounds separated from inactive range
     return parameter
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 83910b27f..3d0481f05 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -345,7 +345,6 @@ def _drop_parameters(self, parameter_names: Collection[str]) -> SubspaceContinuo
     def _enforce_cardinality_constraints_via_assignment(
         self,
         inactive_parameter_names: Collection[str],
-        threshold: float = 1e-8,
     ) -> SubspaceContinuous:
         """Create a copy of the subspace with fixed inactive parameters.
 
@@ -356,7 +355,6 @@ def _enforce_cardinality_constraints_via_assignment(
 
         Args:
             inactive_parameter_names: The names of the parameter to be inactivated.
-            threshold: The threshold for a parameter to be considered active.
 
         Returns:
             A new subspace with fixed inactive parameters and no cardinality
@@ -374,7 +372,13 @@ def _enforce_cardinality_constraints_via_assignment(
             if p.name in inactive_parameter_names:
                 p_adjusted = _FixedNumericalContinuousParameter(name=p.name, value=0.0)
             elif p.name in active_parameter_names:
-                p_adjusted = activate_parameter(p)
+                # cardinality constraint object containing the current parameter
+                cardinality_constraint_with_p = [
+                    c for c in self.constraints_cardinality if p.name in c.parameters
+                ][0]
+                p_adjusted = activate_parameter(
+                    p, cardinality_constraint_with_p.get_threshold(p)
+                )
             else:
                 p_adjusted = p
             adjusted_parameters.append(p_adjusted)

From 142b1ecb4b5eeac90a584ad502e93fbc4bc70879 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Wed, 8 Jan 2025 21:59:26 +0100
Subject: [PATCH 059/108] Refine check cardinaltiy constraint fulfillment logic

- Support checking minimum cardinality or maximum cardinatliy
- Adapt to threshold per cardinality
- Update related tests
---
 baybe/recommenders/pure/bayesian/botorch.py   |  7 +-
 baybe/utils/cardinality_constraints.py        | 99 +++++++++++++------
 .../test_cardinality_constraint_continuous.py | 46 ++++-----
 .../test_constraints_continuous.py            |  6 +-
 4 files changed, 99 insertions(+), 59 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 356aae00f..4f5a61028 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -30,7 +30,7 @@
     SubspaceContinuous,
     SubspaceDiscrete,
 )
-from baybe.utils.cardinality_constraints import is_min_cardinality_fulfilled
+from baybe.utils.cardinality_constraints import is_cardinality_fulfilled
 from baybe.utils.dataframe import to_tensor
 from baybe.utils.plotting import to_string
 from baybe.utils.sampling_algorithms import (
@@ -289,12 +289,13 @@ def _recommend_continuous_with_cardinality_constraints(
         points, acqf_value = self._optimize_continuous_subspaces(subspaces, batch_size)
 
         # Check if any minimum cardinality constraints are violated
-        if not is_min_cardinality_fulfilled(
+        if not is_cardinality_fulfilled(
             subspace_continuous,
             pd.DataFrame(points, columns=subspace_continuous.parameter_names),
+            "min",
         ):
             warnings.warn(
-                "Minimum cardinality constraints are not guaranteed.",
+                "At least one minimum cardinality constraint is violated.",
                 MinimumCardinalityViolatedWarning,
             )
 
diff --git a/baybe/utils/cardinality_constraints.py b/baybe/utils/cardinality_constraints.py
index 5c2b178cc..4be92ec7f 100644
--- a/baybe/utils/cardinality_constraints.py
+++ b/baybe/utils/cardinality_constraints.py
@@ -1,79 +1,120 @@
 """Utilities related to cardinality constraints."""
 
+from typing import Literal
+
 import numpy as np
 import pandas as pd
 
-from baybe.parameters import NumericalContinuousParameter
+from baybe.parameters.utils import SMALLEST_FLOAT32
 from baybe.searchspace import SubspaceContinuous
+from baybe.utils.interval import Interval
 
 
 def count_near_zeros(
-    parameters: tuple[NumericalContinuousParameter, ...], points: pd.DataFrame
+    thresholds: tuple[Interval, ...], points: pd.DataFrame
 ) -> np.ndarray:
     """Return the counts of near-zeros in the recommendations.
 
     Args:
-        parameters: A list of parameter objects according to which the counts of
+        thresholds: A list of thresholds for according to which the counts of
             near-zeros in the recommendations should be calculated.
         points: The recommendations of the parameter objects.
 
     Raises:
-        ValueError: If parameters does not cover all parameters present in points.
+        ValueError: If number of thresholds differs from the number of
+            parameters in points.
 
     Returns:
         The counts of near-zero values in the recommendations.
 
 
     """
-    p_names = [p.name for p in parameters]
-    if not set(points.columns).issubset(set(p_names)):
+    if len(thresholds) != len(points.columns):
         raise ValueError(
-            "Parameters must cover all parameters present in points: "
-            "parameter names in parameters are: {p_name} and parameter "
-            "names from points are: {points.columns}."
+            f"The size of thresholds ({len(thresholds)}) must be the same as the "
+            f"number of parameters ({len(points.columns)}) in points."
         )
+    # Get the lower threshold for determining zeros/non-zeros. When the
+    # lower_threshold is zero, we replace it with a very small negative value to have
+    # the threshold being an open-support.
+    lower_threshold = np.array(
+        [min(threshold.lower, -SMALLEST_FLOAT32) for threshold in thresholds]
+    )
+    lower_threshold = np.broadcast_to(lower_threshold, points.shape)
 
-    # Only keep parameters that are present in points; The order of parameters
-    # aligns with that in points.
-    parameters_filtered_sorted = (
-        p for p_name in points.columns for p in parameters if p.name == p_name
+    # Get the upper threshold for determining zeros/non-zeros. When the
+    # upper_threshold is zero, we replace it with a very small positive value.
+    upper_threshold = np.array(
+        [max(threshold.upper, SMALLEST_FLOAT32) for threshold in thresholds]
     )
+    upper_threshold = np.broadcast_to(upper_threshold, points.shape)
 
-    # Boolean values indicating whether the candidate is near-zero: True for near-zero.
-    p_thresholds = np.array([p.near_zero_threshold for p in parameters_filtered_sorted])
-    p_thresholds_mask = np.broadcast_to(p_thresholds, points.shape)
-    near_zero_flags = (points > -p_thresholds_mask) & (points < p_thresholds_mask)
+    # Boolean values indicating whether the candidates is near-zero: True for is
+    # near-zero.
+    near_zero_flags = (points > lower_threshold) & (points < upper_threshold)
     return np.sum(near_zero_flags, axis=1)
 
 
-def is_min_cardinality_fulfilled(
-    subspace_continuous: SubspaceContinuous, batch: pd.DataFrame
+def is_cardinality_fulfilled(
+    subspace_continuous: SubspaceContinuous,
+    batch: pd.DataFrame,
+    type_cardinality: Literal["min", "max"],
 ) -> bool:
-    """Check whether any minimum cardinality constraints are fulfilled.
+    """Check whether all minimum cardinality constraints are fulfilled.
 
     Args:
-        subspace_continuous: The continuous subspace from which candidates are
-            generated.
+        subspace_continuous:
+            The continuous subspace from which candidates are generated.
         batch: The recommended batch
+        type_cardinality:
+            "min" or "max". "min" indicates all minimum cardinality constraints are
+            checked; "max" for all maximum cardinality constraints.
 
     Returns:
         Return "True" if all minimum cardinality constraints are fulfilled; "False"
         otherwise.
+
+    Raises:
+        ValueError: If type_cardinality is neither "min" nor "max".
     """
+    if type_cardinality not in ["min", "max"]:
+        raise ValueError(
+            f"Unknown type of cardinality. Only support min or max but "
+            f"{type_cardinality=}."
+        )
+
     if len(subspace_continuous.constraints_cardinality) == 0:
         return True
 
     for c in subspace_continuous.constraints_cardinality:
-        if c.min_cardinality == 0:
+        # No need to check this redundant cardinality constraint
+        if (c.min_cardinality == 0) and type_cardinality == "min":
             continue
 
-        # Counts the near-zero elements
+        if (c.max_cardinality == len(c.parameters)) and type_cardinality == "max":
+            continue
+
+        # Batch of parameters that are related to cardinality constraint
         batch_related_to_c = batch[c.parameters]
-        n_near_zeros = count_near_zeros(
-            subspace_continuous.parameters, batch_related_to_c
-        )
 
-        # When the minimum cardinality is violated
-        if np.any(len(c.parameters) - n_near_zeros < c.min_cardinality):
+        # Parameters related to cardinality constraint
+        parameters_in_c = subspace_continuous.get_parameters_by_name(c.parameters)
+
+        # Thresholds of parameters that are related to the cardinality constraint
+        thresholds = tuple(c.get_threshold(p) for p in parameters_in_c)
+
+        # Count the number of near-zero elements
+        n_near_zeros = count_near_zeros(thresholds, batch_related_to_c)
+
+        # When any minimum cardinality is violated
+        if type_cardinality == "min" and np.any(
+            len(c.parameters) - n_near_zeros < c.min_cardinality
+        ):
+            return False
+
+        # When any maximum cardinality is violated
+        if type_cardinality == "max" and np.any(
+            len(c.parameters) - n_near_zeros > c.max_cardinality
+        ):
             return False
     return True
diff --git a/tests/constraints/test_cardinality_constraint_continuous.py b/tests/constraints/test_cardinality_constraint_continuous.py
index 8f5bbbc2c..5c4780109 100644
--- a/tests/constraints/test_cardinality_constraint_continuous.py
+++ b/tests/constraints/test_cardinality_constraint_continuous.py
@@ -17,33 +17,30 @@
 from baybe.recommenders import BotorchRecommender
 from baybe.searchspace.core import SearchSpace, SubspaceContinuous
 from baybe.targets.numerical import NumericalTarget
-from baybe.utils.cardinality_constraints import count_near_zeros
+from baybe.utils.cardinality_constraints import is_cardinality_fulfilled
 
 
 def _validate_cardinality_constrained_batch(
+    subspace_continuous: SubspaceContinuous,
     batch: pd.DataFrame,
-    min_cardinality: int,
-    max_cardinality: int,
     batch_size: int,
-    parameters: tuple[NumericalContinuousParameter],
     captured_warnings: list[WarningMessage | None],
 ):
     """Validate that a cardinality-constrained batch fulfills the necessary conditions.
 
     Args:
+        subspace_continuous: The continuous subspace from which to recommend the points.
         batch: Batch to validate.
-        min_cardinality: Minimum required cardinality.
-        max_cardinality: Maximum allowed cardinality.
-        batch_size: Requested batch size.
-        parameters: A list of parameters for which recommendations are provided.
+        batch_size: The number of points to be recommended.
         captured_warnings: A list of captured warnings.
     """
     # Assert that the maximum cardinality constraint is fulfilled
-    n_nonzeros = len(parameters) - count_near_zeros(parameters, batch)
-    assert np.all(n_nonzeros <= max_cardinality)
+    assert is_cardinality_fulfilled(subspace_continuous, batch, "max")
 
     # Check whether the minimum cardinality constraint is fulfilled
-    is_min_cardinality_fulfilled = np.all(n_nonzeros >= min_cardinality)
+    is_min_cardinality_fulfilled = is_cardinality_fulfilled(
+        subspace_continuous, batch, "min"
+    )
 
     # A warning must be raised when the minimum cardinality constraint is not fulfilled
     if not is_min_cardinality_fulfilled:
@@ -62,8 +59,13 @@ def _validate_cardinality_constrained_batch(
     # We thus include this check as a safety net for catching regressions. If it
     # turns out the check fails because we observe degenerate batches as actual
     # recommendations, we need to invent something smarter.
+    max_cardinalities = [
+        c.max_cardinality for c in subspace_continuous.constraints_cardinality
+    ]
     if len(unique_row := batch.drop_duplicates()) == 1:
-        assert (unique_row.iloc[0] == 0.0).all() and (max_cardinality == 0)
+        assert (unique_row.iloc[0] == 0.0).all() and all(
+            max_cardinality == 0 for max_cardinality in max_cardinalities
+        )
 
 
 # Combinations of cardinalities to be tested
@@ -96,15 +98,15 @@ def test_sampling_cardinality_constraint(cardinality_bounds: tuple[int, int]):
         ),
     )
 
-    subspace = SubspaceContinuous(parameters=parameters, constraints_nonlin=constraints)
+    subspace_continous = SubspaceContinuous(
+        parameters=parameters, constraints_nonlin=constraints
+    )
 
     with warnings.catch_warnings(record=True) as w:
-        samples = subspace.sample_uniform(BATCH_SIZE)
+        samples = subspace_continous.sample_uniform(BATCH_SIZE)
 
     # Assert that the constraint conditions hold
-    _validate_cardinality_constrained_batch(
-        samples, min_cardinality, max_cardinality, BATCH_SIZE, parameters, w
-    )
+    _validate_cardinality_constrained_batch(subspace_continous, samples, BATCH_SIZE, w)
 
 
 def test_polytope_sampling_with_cardinality_constraint():
@@ -147,18 +149,16 @@ def test_polytope_sampling_with_cardinality_constraint():
             min_cardinality=MIN_CARDINALITY,
         ),
     ]
-    searchspace = SearchSpace.from_product(parameters, constraints)
+    subspace_continous = SubspaceContinuous.from_product(parameters, constraints)
 
     with warnings.catch_warnings(record=True) as w:
-        samples = searchspace.continuous.sample_uniform(BATCH_SIZE)
+        samples = subspace_continous.sample_uniform(BATCH_SIZE)
 
     # Assert that the constraint conditions hold
     _validate_cardinality_constrained_batch(
-        samples[params_cardinality],
-        MIN_CARDINALITY,
-        MAX_CARDINALITY,
+        subspace_continous,
+        samples,
         BATCH_SIZE,
-        tuple(p for p in parameters if p.name in params_cardinality),
         w,
     )
 
diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py
index e326ebb83..182f18da0 100644
--- a/tests/constraints/test_constraints_continuous.py
+++ b/tests/constraints/test_constraints_continuous.py
@@ -13,7 +13,7 @@
 from baybe.recommenders.pure.bayesian.base import BayesianRecommender
 from baybe.recommenders.pure.bayesian.botorch import BotorchRecommender
 from baybe.recommenders.pure.nonpredictive.sampling import RandomRecommender
-from baybe.searchspace.core import SearchSpace
+from baybe.searchspace import SearchSpace
 from baybe.targets.numerical import NumericalTarget
 from tests.conftest import run_iterations
 from tests.constraints.test_cardinality_constraint_continuous import (
@@ -111,11 +111,9 @@ def test_cardinality_constraint(recommender):
 
     # Assert that the constraint conditions hold
     _validate_cardinality_constrained_batch(
+        searchspace.continuous,
         recommendation,
-        MIN_CARDINALITY,
-        MAX_CARDINALITY,
         BATCH_SIZE,
-        tuple(parameters),
         w,
     )
 

From 04f145c72923ffe22f55d16a3e13d30069e0f102 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Thu, 9 Jan 2025 09:07:51 +0100
Subject: [PATCH 060/108] Remove threshold related attribute and method in
 numerical continuous parameter

---
 baybe/parameters/numerical.py | 18 ------------------
 1 file changed, 18 deletions(-)

diff --git a/baybe/parameters/numerical.py b/baybe/parameters/numerical.py
index 4c087199e..439a80050 100644
--- a/baybe/parameters/numerical.py
+++ b/baybe/parameters/numerical.py
@@ -112,9 +112,6 @@ class NumericalContinuousParameter(ContinuousParameter):
     bounds: Interval = field(default=None, converter=convert_bounds)
     """The bounds of the parameter."""
 
-    near_zero_threshold: float = field(default=1e-5, converter=float)
-    """A threshold for determining if the value is considered near-zero."""
-
     @bounds.validator
     def _validate_bounds(self, _: Any, value: Interval) -> None:  # noqa: DOC101, DOC103
         """Validate bounds.
@@ -152,21 +149,6 @@ def summary(self) -> dict:
         )
         return param_dict
 
-    def is_near_zero(self, item: float) -> bool:
-        """Return whether an item is near-zero.
-
-        Important:
-            Value in the open interval (-near_zero_threshold, near_zero_threshold)
-            will be treated as near_zero.
-
-        Args:
-            item: The value to be checked.
-
-        Returns:
-            ``True`` if the value is near-zero, ``False`` otherwise.
-        """
-        return abs(item) < self.near_zero_threshold
-
 
 @define(frozen=True, slots=False)
 class _FixedNumericalContinuousParameter(ContinuousParameter):

From 55a7ba3730955af2eb88197155ac6abd55975886 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Thu, 9 Jan 2025 13:59:35 +0100
Subject: [PATCH 061/108] Make zero-checking and threshold definition
 compatible

* Assure parameter bounds cover zero
* Check invalid "activate_parameter" option first
---
 baybe/parameters/utils.py              | 65 ++++++++++++--------
 baybe/utils/cardinality_constraints.py | 85 ++++++++++++--------------
 2 files changed, 78 insertions(+), 72 deletions(-)

diff --git a/baybe/parameters/utils.py b/baybe/parameters/utils.py
index 0acb65896..c81b038ae 100644
--- a/baybe/parameters/utils.py
+++ b/baybe/parameters/utils.py
@@ -3,18 +3,16 @@
 from collections.abc import Callable, Collection
 from typing import Any, TypeVar
 
-import numpy as np
 import pandas as pd
 from attrs import evolve
 
 from baybe.parameters.base import Parameter
-from baybe.parameters.numerical import NumericalContinuousParameter
+from baybe.parameters.numerical import (
+    NumericalContinuousParameter,
+    _FixedNumericalContinuousParameter,
+)
 from baybe.utils.interval import Interval
 
-# TODO: Check whether it has been defined in BayBE?
-SMALLEST_FLOAT32 = np.finfo(np.float32).tiny
-"""The smallest 32 bit float number."""
-
 _TParameter = TypeVar("_TParameter", bound=Parameter)
 
 
@@ -100,7 +98,7 @@ def sort_parameters(parameters: Collection[Parameter]) -> tuple[Parameter, ...]:
 def activate_parameter(
     parameter: NumericalContinuousParameter,
     thresholds: Interval,
-) -> NumericalContinuousParameter:
+) -> NumericalContinuousParameter | _FixedNumericalContinuousParameter:
     """Activates a given parameter by moving its bounds away from zero.
 
     Important:
@@ -111,7 +109,7 @@ def activate_parameter(
 
     Args:
         parameter: The parameter to be activated.
-        thresholds: The thresholds of the inactive region of the parameter.
+        thresholds: The thresholds of the inactive range of the parameter.
 
     Returns:
         A copy of the parameter with adjusted bounds.
@@ -125,39 +123,54 @@ def activate_parameter(
     upper_bound = parameter.bounds.upper
 
     if not thresholds.contains(0.0):
-        raise ValueError("The thresholds must cover zero.")
+        raise ValueError(
+            f"The thresholds must cover zero but ({thresholds.lower}, "
+            f"{thresholds.upper}) is given."
+        )
 
-    # When the lower/upper threshold is zero, it is slightly adjusted to and used as
-    # thresholf for checking the inactive range.
-    # Check ContinuousCardinalityConstraint.get_threshold(parameter) for the definition
-    # of threshold of inactive (near-zero) region.
-    lower_threshold_for_inactive_range = min(thresholds.lower, -SMALLEST_FLOAT32)
-    upper_threshold_for_inactive_range = max(thresholds.upper, SMALLEST_FLOAT32)
+    if not parameter.bounds.contains(0.0):
+        raise ValueError(
+            f"The parameter bounds must cover zero but "
+            f"({parameter.bounds.lower}, {parameter.bounds.upper}) is "
+            f"given."
+        )
 
     def in_inactive_range(x: float) -> bool:
         """Return true when x is within the inactive range."""
-        return (
-            lower_threshold_for_inactive_range < x < upper_threshold_for_inactive_range
-        )
-
-    # When both bounds in inactive range
+        if thresholds.lower == 0.0:
+            return thresholds.lower <= x < thresholds.upper
+        if thresholds.upper == 0.0:
+            return thresholds.lower < x <= thresholds.upper
+        return thresholds.lower < x < thresholds.upper
+
+    # Note: When both bounds in inactive range. This step must be checked first to catch
+    # all possible cases when a parameter cannot be activated.
     if in_inactive_range(lower_bound) and in_inactive_range(upper_bound):
         raise ValueError(
             f"Parameter '{parameter.name}' cannot be set active since its "
             f"bounds {parameter.bounds.to_tuple()} are entirely contained in the "
-            f"inactive range [-{lower_threshold_for_inactive_range},"
-            f" {upper_threshold_for_inactive_range}]."
+            f"inactive range ({thresholds.lower}, {thresholds.upper})."
         )
 
-    # When the upper bound is in near-zero range, reduce it to the lower threshold of
+    # When the upper bound is in inactive range, move it to the lower threshold of the
     # inactive region.
-    if lower_bound <= thresholds.lower and in_inactive_range(upper_bound):
+    if lower_bound < thresholds.lower and in_inactive_range(upper_bound):
         return evolve(parameter, bounds=(lower_bound, thresholds.lower))
 
-    # When the lower bound is in near-zero range, uplift it to the upper threshold of
+    if lower_bound == thresholds.lower and in_inactive_range(upper_bound):
+        return _FixedNumericalContinuousParameter(
+            name=parameter.name, value=thresholds.lower
+        )
+
+    # When the lower bound is in inactive range, move it to the upper threshold of
     # the inactive region
-    if upper_bound >= thresholds.upper and in_inactive_range(lower_bound):
+    if upper_bound > thresholds.upper and in_inactive_range(lower_bound):
         return evolve(parameter, bounds=(thresholds.upper, upper_bound))
 
+    if upper_bound == thresholds.upper and in_inactive_range(lower_bound):
+        return _FixedNumericalContinuousParameter(
+            name=parameter.name, value=thresholds.upper
+        )
+
     # Both bounds separated from inactive range
     return parameter
diff --git a/baybe/utils/cardinality_constraints.py b/baybe/utils/cardinality_constraints.py
index 4be92ec7f..f93d0ec5c 100644
--- a/baybe/utils/cardinality_constraints.py
+++ b/baybe/utils/cardinality_constraints.py
@@ -5,54 +5,46 @@
 import numpy as np
 import pandas as pd
 
-from baybe.parameters.utils import SMALLEST_FLOAT32
 from baybe.searchspace import SubspaceContinuous
 from baybe.utils.interval import Interval
 
 
-def count_near_zeros(
-    thresholds: tuple[Interval, ...], points: pd.DataFrame
-) -> np.ndarray:
-    """Return the counts of near-zeros in the recommendations.
+def count_zeros(thresholds: tuple[Interval, ...], points: pd.DataFrame) -> np.ndarray:
+    """Return the counts of zeros in the recommendations.
 
     Args:
-        thresholds: A list of thresholds for according to which the counts of
-            near-zeros in the recommendations should be calculated.
+        thresholds: A list of thresholds according to which the counts of zeros
+            in the recommendations should be calculated.
         points: The recommendations of the parameter objects.
 
-    Raises:
-        ValueError: If number of thresholds differs from the number of
-            parameters in points.
-
     Returns:
-        The counts of near-zero values in the recommendations.
-
+        The counts of zero parameters in the recommendations.
 
+    Raises:
+        ValueError: If the number of thresholds differs from the number of
+            parameters in points.
     """
     if len(thresholds) != len(points.columns):
         raise ValueError(
             f"The size of thresholds ({len(thresholds)}) must be the same as the "
             f"number of parameters ({len(points.columns)}) in points."
         )
-    # Get the lower threshold for determining zeros/non-zeros. When the
-    # lower_threshold is zero, we replace it with a very small negative value to have
-    # the threshold being an open-support.
-    lower_threshold = np.array(
-        [min(threshold.lower, -SMALLEST_FLOAT32) for threshold in thresholds]
-    )
-    lower_threshold = np.broadcast_to(lower_threshold, points.shape)
-
-    # Get the upper threshold for determining zeros/non-zeros. When the
-    # upper_threshold is zero, we replace it with a very small positive value.
-    upper_threshold = np.array(
-        [max(threshold.upper, SMALLEST_FLOAT32) for threshold in thresholds]
-    )
-    upper_threshold = np.broadcast_to(upper_threshold, points.shape)
-
-    # Boolean values indicating whether the candidates is near-zero: True for is
-    # near-zero.
-    near_zero_flags = (points > lower_threshold) & (points < upper_threshold)
-    return np.sum(near_zero_flags, axis=1)
+    # Get the lower/upper thresholds for determining zeros/non-zeros
+    lower_thresholds = np.array([threshold.lower for threshold in thresholds])
+    lower_thresholds = np.broadcast_to(lower_thresholds, points.shape)
+
+    upper_thresholds = np.array([threshold.upper for threshold in thresholds])
+    upper_thresholds = np.broadcast_to(upper_thresholds, points.shape)
+
+    # Boolean values indicating whether the candidates are treated zeros: True for zero
+    zero_flags = (points > lower_thresholds) & (points < upper_thresholds)
+
+    # Correct the comparison on the special boundary: zero. This step is needed
+    # because when the lower_threshold = 0, a value v with lower_threshold <= v <
+    # upper_threshold should be treated zero.
+    zero_flags = (points == 0.0) | zero_flags
+
+    return np.sum(zero_flags, axis=1)
 
 
 def is_cardinality_fulfilled(
@@ -60,19 +52,18 @@ def is_cardinality_fulfilled(
     batch: pd.DataFrame,
     type_cardinality: Literal["min", "max"],
 ) -> bool:
-    """Check whether all minimum cardinality constraints are fulfilled.
+    """Check whether all minimum (or maximum) cardinality constraints are fulfilled.
 
     Args:
-        subspace_continuous:
-            The continuous subspace from which candidates are generated.
+        subspace_continuous: The continuous subspace from which candidates are
+            generated.
         batch: The recommended batch
-        type_cardinality:
-            "min" or "max". "min" indicates all minimum cardinality constraints are
-            checked; "max" for all maximum cardinality constraints.
+        type_cardinality: "min" or "max". "min" indicates all minimum cardinality
+            constraints will be checked; "max" for all maximum cardinality constraints.
 
     Returns:
-        Return "True" if all minimum cardinality constraints are fulfilled; "False"
-        otherwise.
+        Return "True" if all minimum (or maximum) cardinality constraints are
+        fulfilled; "False" otherwise.
 
     Raises:
         ValueError: If type_cardinality is neither "min" nor "max".
@@ -80,14 +71,16 @@ def is_cardinality_fulfilled(
     if type_cardinality not in ["min", "max"]:
         raise ValueError(
             f"Unknown type of cardinality. Only support min or max but "
-            f"{type_cardinality=}."
+            f"{type_cardinality=} is given."
         )
 
     if len(subspace_continuous.constraints_cardinality) == 0:
         return True
 
     for c in subspace_continuous.constraints_cardinality:
-        # No need to check this redundant cardinality constraint
+        # No need to check the redundant cardinality constraints that are
+        # - min_cardinality = 0
+        # - max_cardinality = len(parameters)
         if (c.min_cardinality == 0) and type_cardinality == "min":
             continue
 
@@ -103,18 +96,18 @@ def is_cardinality_fulfilled(
         # Thresholds of parameters that are related to the cardinality constraint
         thresholds = tuple(c.get_threshold(p) for p in parameters_in_c)
 
-        # Count the number of near-zero elements
-        n_near_zeros = count_near_zeros(thresholds, batch_related_to_c)
+        # Count the number of zeros
+        n_zeros = count_zeros(thresholds, batch_related_to_c)
 
         # When any minimum cardinality is violated
         if type_cardinality == "min" and np.any(
-            len(c.parameters) - n_near_zeros < c.min_cardinality
+            len(c.parameters) - n_zeros < c.min_cardinality
         ):
             return False
 
         # When any maximum cardinality is violated
         if type_cardinality == "max" and np.any(
-            len(c.parameters) - n_near_zeros > c.max_cardinality
+            len(c.parameters) - n_zeros > c.max_cardinality
         ):
             return False
     return True

From 78b115f1cba5db9fdf450684b26d762773e05b15 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Thu, 9 Jan 2025 14:46:51 +0100
Subject: [PATCH 062/108] Add activate parameter step in random sampler

---
 baybe/searchspace/continuous.py | 14 ++++++++++----
 1 file changed, 10 insertions(+), 4 deletions(-)

diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 3d0481f05..70493e7e4 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -529,10 +529,16 @@ def _sample_from_polytope_with_cardinality_constraints(
             # Randomly set some parameters inactive
             inactive_params_sample = self._sample_inactive_parameters(1)[0]
 
-            # TODO: active parameters must be guaranteed non-zero!
-            # Remove the inactive parameters from the search space
-            subspace_without_cardinality_constraint = self._drop_parameters(
-                inactive_params_sample
+            # Remove the inactive parameters from the search space. In the first
+            # step, the active parameters get activated and inactive parameters are
+            # fixed to zero. The first step helps ensure active parameters stay
+            # non-zero, especially when one boundary is zero. The second step is
+            # optional and it helps reduce the parameter space with certain
+            # computational cost.
+            subspace_without_cardinality_constraint = (
+                self._enforce_cardinality_constraints_via_assignment(
+                    inactive_params_sample
+                )._drop_parameters(inactive_params_sample)
             )
 
             # TODO: Replace ValueError with customized erorr. See

From 68045a7220332ad1f9ac4fe73e7adee15d40aab0 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Thu, 9 Jan 2025 16:47:42 +0100
Subject: [PATCH 063/108] Update CHANGELOG.md

---
 CHANGELOG.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae87bf664..4f84d4c58 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,9 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   optimization behavior in the presence of multiple subspaces
 - Utilities `inactive_parameter_combinations` and`n_inactive_parameter_combinations` 
   in both `ContinuousCardinalityConstraint`and `SubspaceContinuous`
-- Attribute `near_zero_threshold` and utility `is_near_zero` to 
-  `NumericalContinuousParameter`
-- Utilities `count_near_zeros` and `is_min_cardinality_fulfilled`
+- Attribute `relative_threshold` and method `get_threshold` to 
+  `ContinuousCardinalityConstraint`
+- Utilities `count_zeros` and `is_cardinality_fulfilled`
 
 ### Changed
 - `SubstanceParameter` encodings are now computed exclusively with the

From e6e2e97c4319d4f6fd77d4d81100c172807e64a3 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Thu, 9 Jan 2025 17:03:09 +0100
Subject: [PATCH 064/108] Fix type hint in continuous numerical parameter
 classes

---
 baybe/parameters/numerical.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/baybe/parameters/numerical.py b/baybe/parameters/numerical.py
index 439a80050..e58afa6af 100644
--- a/baybe/parameters/numerical.py
+++ b/baybe/parameters/numerical.py
@@ -136,7 +136,7 @@ def is_in_range(self, item: float) -> bool:
 
     @override
     @property
-    def comp_rep_columns(self) -> tuple[str, ...]:
+    def comp_rep_columns(self) -> tuple[str]:
         return (self.name,)
 
     @override
@@ -171,7 +171,7 @@ def is_in_range(self, item: float) -> bool:
 
     @override
     @property
-    def comp_rep_columns(self) -> tuple[str, ...]:
+    def comp_rep_columns(self) -> tuple[str]:
         return (self.name,)
 
     @override

From a30b0092e8482c621c0b9c882737783468dc7d5f Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Mon, 13 Jan 2025 17:07:57 +0100
Subject: [PATCH 065/108] Test activate parameter function

---
 tests/utils/test_parameters.py | 199 +++++++++++++++++++++++++++++++++
 1 file changed, 199 insertions(+)
 create mode 100644 tests/utils/test_parameters.py

diff --git a/tests/utils/test_parameters.py b/tests/utils/test_parameters.py
new file mode 100644
index 000000000..b038e4e9d
--- /dev/null
+++ b/tests/utils/test_parameters.py
@@ -0,0 +1,199 @@
+"""Tests for parameter utilities."""
+
+import pytest
+from pytest import param
+
+from baybe.parameters import NumericalContinuousParameter
+from baybe.parameters.numerical import _FixedNumericalContinuousParameter
+from baybe.parameters.utils import activate_parameter
+from baybe.utils.interval import Interval
+
+
+def mirror_interval(interval: Interval) -> Interval:
+    """Return an interval copy mirrored around the origin."""
+    return Interval(lower=-interval.upper, upper=-interval.lower)
+
+
+@pytest.mark.parametrize(
+    (
+        "bounds",
+        "thresholds",
+        "is_valid",
+        "expected_bounds",
+    ),
+    [
+        # one-side bounds, two-side thresholds
+        param(
+            Interval(lower=0.0, upper=1.0),
+            Interval(lower=-1.0, upper=1.5),
+            False,
+            None,
+            id="oneside_bounds_in_twoside_thresholds",
+        ),
+        param(
+            Interval(lower=0.0, upper=1.0),
+            Interval(lower=-1.0, upper=1.0),
+            True,
+            Interval(lower=1.0, upper=1.0),
+            id="oneside_bounds_in_twoside_thresholds_fixed_value",
+        ),
+        param(
+            Interval(lower=0.0, upper=1.0),
+            Interval(lower=-1.0, upper=0.5),
+            True,
+            Interval(lower=0.5, upper=1.0),
+            id="oneside_bounds_intersected_with_twoside_thresholds",
+        ),
+        # one-side bounds, one-side thresholds
+        param(
+            Interval(lower=0.0, upper=1.0),
+            Interval(lower=-1.0, upper=0.0),
+            True,
+            Interval(lower=0.0, upper=1.0),
+            id="oneside_bounds_intersected_on_single_point_with_oneside_thresholds",
+        ),
+        param(
+            Interval(lower=0.0, upper=1.0),
+            Interval(lower=0.0, upper=0.5),
+            True,
+            Interval(lower=0.5, upper=1.0),
+            id="oneside_bounds_cover_oneside_thresholds",
+        ),
+        param(
+            Interval(lower=0.0, upper=1.0),
+            Interval(lower=0.0, upper=1.0),
+            True,
+            Interval(lower=1.0, upper=1.0),
+            id="oneside_bounds_match_oneside_thresholds",
+        ),
+        param(
+            Interval(lower=0.0, upper=1.0),
+            Interval(lower=0.0, upper=1.1),
+            False,
+            None,
+            id="oneside_bounds_in_oneside_thresholds",
+        ),
+        # Two-side bounds. One-side thresholds do not differ from two-side threshold
+        # in these cases. Hence, use two-side thresholds.
+        param(
+            Interval(lower=-0.5, upper=1.0),
+            Interval(lower=-1.0, upper=1.1),
+            False,
+            None,
+            id="twoside_bounds_in_twoside_thresholds",
+        ),
+        param(
+            Interval(lower=-0.5, upper=1.0),
+            Interval(lower=-0.5, upper=1.0),
+            True,
+            Interval(lower=-0.5, upper=1.0),
+            id="twoside_bounds_match_twoside_thresholds",
+        ),
+        param(
+            Interval(lower=-0.6, upper=1.1),
+            Interval(lower=-0.5, upper=1.0),
+            True,
+            Interval(lower=-0.6, upper=1.1),
+            id="twoside_bounds_cover_twoside_thresholds",
+        ),
+        param(
+            Interval(lower=-0.6, upper=1.1),
+            Interval(lower=-1.0, upper=0.5),
+            True,
+            Interval(lower=0.5, upper=1.1),
+            id="twoside_bounds_intersected_with_twoside_thresholds",
+        ),
+        param(
+            Interval(lower=-0.6, upper=0.5),
+            Interval(lower=-1.0, upper=0.5),
+            True,
+            Interval(lower=0.5, upper=0.5),
+            id="twoside_bounds_partial_in_twoside_thresholds",
+        ),
+        param(
+            Interval(lower=-1.0, upper=0.5),
+            Interval(lower=-0.6, upper=0.5),
+            True,
+            Interval(lower=-1.0, upper=0.5),
+            id="twoside_bounds_partial_cover_twoside_thresholds",
+        ),
+    ],
+)
+@pytest.mark.parametrize("mirror", [False, True])
+def test_activate_parameter(
+    bounds: Interval,
+    thresholds: Interval,
+    is_valid: bool,
+    expected_bounds: Interval | None,
+    mirror: bool,
+) -> None:
+    """Test that the utility correctly activate a parameter.
+
+    Args:
+        bounds: the bounds of the parameter to activate
+        thresholds: the thresholds of inactive range
+        is_valid: boolean variable indicating whether a parameter is returned from
+            activate_parameter
+        expected_bounds: the bounds of the activated parameter if one is returned
+        mirror: if true both bounds and thresholds get mirrored
+
+    Returns:
+        None
+    """
+    if mirror:
+        bounds = mirror_interval(bounds)
+        thresholds = mirror_interval(thresholds)
+    if mirror and is_valid:
+        expected_bounds = mirror_interval(expected_bounds)
+
+    parameter = NumericalContinuousParameter("parameter", bounds=bounds)
+
+    if is_valid:
+        activated_parameter = activate_parameter(parameter, thresholds)
+        assert activated_parameter.bounds == expected_bounds
+        if expected_bounds.is_degenerate:
+            assert isinstance(activated_parameter, _FixedNumericalContinuousParameter)
+    else:
+        with pytest.raises(ValueError, match="cannot be set active"):
+            activate_parameter(parameter, thresholds)
+
+
+@pytest.mark.parametrize(
+    ("bounds", "thresholds", "match"),
+    [
+        param(
+            Interval(lower=-0.5, upper=0.5),
+            Interval(lower=0.5, upper=1.0),
+            "The thresholds must cover zero",
+            id="invalid_thresholds",
+        ),
+        param(
+            Interval(lower=0.5, upper=1.0),
+            Interval(lower=-0.5, upper=0.5),
+            "The parameter bounds must cover zero",
+            id="invalid_bounds",
+        ),
+    ],
+)
+@pytest.mark.parametrize("mirror", [False, True])
+def test_invalid_activate_parameter(
+    bounds: Interval, thresholds: Interval, match: str, mirror: bool
+) -> None:
+    """Test that invalid bounds or thresholds are given.
+
+    Args:
+        bounds: the bounds of the parameter to activate
+        thresholds: the thresholds of inactive range
+        match: error message to match
+        mirror: if true both bounds and thresholds get mirrored
+
+    Returns:
+        None
+    """
+    if mirror:
+        bounds = mirror_interval(bounds)
+        thresholds = mirror_interval(thresholds)
+
+    parameter = NumericalContinuousParameter("parameter", bounds=bounds)
+    with pytest.raises(ValueError, match=match):
+        activate_parameter(parameter, thresholds)

From 983b1a9b727f595d6c8119d356b9ef46421bf406 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Mon, 13 Jan 2025 22:37:36 +0100
Subject: [PATCH 066/108] Correct logic on boundary handling in activate
 paramter

---
 baybe/parameters/utils.py      | 34 ++++++-------
 tests/utils/test_parameters.py | 90 +++++++++-------------------------
 2 files changed, 36 insertions(+), 88 deletions(-)

diff --git a/baybe/parameters/utils.py b/baybe/parameters/utils.py
index c81b038ae..b6dc18c38 100644
--- a/baybe/parameters/utils.py
+++ b/baybe/parameters/utils.py
@@ -9,7 +9,6 @@
 from baybe.parameters.base import Parameter
 from baybe.parameters.numerical import (
     NumericalContinuousParameter,
-    _FixedNumericalContinuousParameter,
 )
 from baybe.utils.interval import Interval
 
@@ -98,7 +97,7 @@ def sort_parameters(parameters: Collection[Parameter]) -> tuple[Parameter, ...]:
 def activate_parameter(
     parameter: NumericalContinuousParameter,
     thresholds: Interval,
-) -> NumericalContinuousParameter | _FixedNumericalContinuousParameter:
+) -> NumericalContinuousParameter:
     """Activates a given parameter by moving its bounds away from zero.
 
     Important:
@@ -135,16 +134,21 @@ def activate_parameter(
             f"given."
         )
 
+    # Note that the definition on the boundary (lower/upper threshold) is vague.
+    # The value on the lower/upper boundary is determined as within inactive_range;
+    # while an activated parameter may take this boundary value (lower/upper
+    # threshold). We allow the misuse of boundary in the "in_inactive_range" and it
+    # is just an utils for checking condition. Ultimately, the "key" threshold
+    # boundary appears as a bound of the activated parameter and this is compatible
+    # with the thresholds defined in ContinuousCardinalityConstraint, as long as the
+    # "key" threshold boundary is not zero. The "key" threshold boundary is always
+    # non-zero when the thresholds are inferred from the bounds of this parameter.
+
     def in_inactive_range(x: float) -> bool:
         """Return true when x is within the inactive range."""
-        if thresholds.lower == 0.0:
-            return thresholds.lower <= x < thresholds.upper
-        if thresholds.upper == 0.0:
-            return thresholds.lower < x <= thresholds.upper
-        return thresholds.lower < x < thresholds.upper
-
-    # Note: When both bounds in inactive range. This step must be checked first to catch
-    # all possible cases when a parameter cannot be activated.
+        return thresholds.lower <= x <= thresholds.upper
+
+    # When both bounds in inactive range.
     if in_inactive_range(lower_bound) and in_inactive_range(upper_bound):
         raise ValueError(
             f"Parameter '{parameter.name}' cannot be set active since its "
@@ -157,20 +161,10 @@ def in_inactive_range(x: float) -> bool:
     if lower_bound < thresholds.lower and in_inactive_range(upper_bound):
         return evolve(parameter, bounds=(lower_bound, thresholds.lower))
 
-    if lower_bound == thresholds.lower and in_inactive_range(upper_bound):
-        return _FixedNumericalContinuousParameter(
-            name=parameter.name, value=thresholds.lower
-        )
-
     # When the lower bound is in inactive range, move it to the upper threshold of
     # the inactive region
     if upper_bound > thresholds.upper and in_inactive_range(lower_bound):
         return evolve(parameter, bounds=(thresholds.upper, upper_bound))
 
-    if upper_bound == thresholds.upper and in_inactive_range(lower_bound):
-        return _FixedNumericalContinuousParameter(
-            name=parameter.name, value=thresholds.upper
-        )
-
     # Both bounds separated from inactive range
     return parameter
diff --git a/tests/utils/test_parameters.py b/tests/utils/test_parameters.py
index b038e4e9d..2c4a3af8c 100644
--- a/tests/utils/test_parameters.py
+++ b/tests/utils/test_parameters.py
@@ -22,100 +22,54 @@ def mirror_interval(interval: Interval) -> Interval:
         "expected_bounds",
     ),
     [
-        # one-side bounds, two-side thresholds
         param(
-            Interval(lower=0.0, upper=1.0),
-            Interval(lower=-1.0, upper=1.5),
+            Interval(lower=-1.0, upper=1.0),
+            Interval(lower=-1.0, upper=1.0),
             False,
             None,
-            id="oneside_bounds_in_twoside_thresholds",
+            id="bounds_on_thresholds",
         ),
         param(
-            Interval(lower=0.0, upper=1.0),
             Interval(lower=-1.0, upper=1.0),
-            True,
-            Interval(lower=1.0, upper=1.0),
-            id="oneside_bounds_in_twoside_thresholds_fixed_value",
-        ),
-        param(
-            Interval(lower=0.0, upper=1.0),
-            Interval(lower=-1.0, upper=0.5),
-            True,
-            Interval(lower=0.5, upper=1.0),
-            id="oneside_bounds_intersected_with_twoside_thresholds",
-        ),
-        # one-side bounds, one-side thresholds
-        param(
-            Interval(lower=0.0, upper=1.0),
-            Interval(lower=-1.0, upper=0.0),
-            True,
-            Interval(lower=0.0, upper=1.0),
-            id="oneside_bounds_intersected_on_single_point_with_oneside_thresholds",
-        ),
-        param(
-            Interval(lower=0.0, upper=1.0),
-            Interval(lower=0.0, upper=0.5),
-            True,
-            Interval(lower=0.5, upper=1.0),
-            id="oneside_bounds_cover_oneside_thresholds",
-        ),
-        param(
-            Interval(lower=0.0, upper=1.0),
-            Interval(lower=0.0, upper=1.0),
-            True,
-            Interval(lower=1.0, upper=1.0),
-            id="oneside_bounds_match_oneside_thresholds",
-        ),
-        param(
-            Interval(lower=0.0, upper=1.0),
-            Interval(lower=0.0, upper=1.1),
+            Interval(lower=-1.5, upper=1.5),
             False,
             None,
-            id="oneside_bounds_in_oneside_thresholds",
+            id="bounds_in_thresholds",
         ),
-        # Two-side bounds. One-side thresholds do not differ from two-side threshold
-        # in these cases. Hence, use two-side thresholds.
         param(
-            Interval(lower=-0.5, upper=1.0),
-            Interval(lower=-1.0, upper=1.1),
+            Interval(lower=-1.0, upper=1.0),
+            Interval(lower=-1.5, upper=1.0),
             False,
             None,
-            id="twoside_bounds_in_twoside_thresholds",
+            id="bounds_in_thresholds_single_side_match",
         ),
         param(
-            Interval(lower=-0.5, upper=1.0),
-            Interval(lower=-0.5, upper=1.0),
+            Interval(lower=-1.0, upper=1.0),
+            Interval(lower=-0.5, upper=0.5),
             True,
-            Interval(lower=-0.5, upper=1.0),
-            id="twoside_bounds_match_twoside_thresholds",
+            Interval(lower=-1.0, upper=1.0),
+            id="thresholds_in_bounds",
         ),
         param(
-            Interval(lower=-0.6, upper=1.1),
+            Interval(lower=-1.0, upper=1.0),
             Interval(lower=-0.5, upper=1.0),
             True,
-            Interval(lower=-0.6, upper=1.1),
-            id="twoside_bounds_cover_twoside_thresholds",
-        ),
-        param(
-            Interval(lower=-0.6, upper=1.1),
-            Interval(lower=-1.0, upper=0.5),
-            True,
-            Interval(lower=0.5, upper=1.1),
-            id="twoside_bounds_intersected_with_twoside_thresholds",
+            Interval(lower=-1.0, upper=-0.5),
+            id="thresholds_in_bounds_single_side_match",
         ),
         param(
-            Interval(lower=-0.6, upper=0.5),
+            Interval(lower=-0.5, upper=1.0),
             Interval(lower=-1.0, upper=0.5),
             True,
-            Interval(lower=0.5, upper=0.5),
-            id="twoside_bounds_partial_in_twoside_thresholds",
+            Interval(lower=0.5, upper=1.0),
+            id="bounds_intersected_with_thresholds",
         ),
         param(
-            Interval(lower=-1.0, upper=0.5),
-            Interval(lower=-0.6, upper=0.5),
+            Interval(lower=0.0, upper=1.0),
+            Interval(lower=-1.0, upper=0.0),
             True,
-            Interval(lower=-1.0, upper=0.5),
-            id="twoside_bounds_partial_cover_twoside_thresholds",
+            Interval(lower=0.0, upper=1.0),
+            id="bounds_intersected_with_thresholds_on_one_point",
         ),
     ],
 )

From bddab62f82e9435a67f41ca0a30f984be64f236e Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Tue, 14 Jan 2025 09:29:13 +0100
Subject: [PATCH 067/108] Ensure parameter bounds cover zero

---
 baybe/constraints/continuous.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py
index 4675ac9d1..ce70d00c7 100644
--- a/baybe/constraints/continuous.py
+++ b/baybe/constraints/continuous.py
@@ -219,7 +219,6 @@ def get_threshold(self, parameter: NumericalContinuousParameter) -> Interval:
             * If lower < 0 and upper = 0, any value v with lower < v <= upper are
             treated zero.
 
-
         Args:
             parameter: The parameter object.
 
@@ -229,12 +228,18 @@ def get_threshold(self, parameter: NumericalContinuousParameter) -> Interval:
         Raises:
             ValueError: when parameter_name is not present in parameter list of this
                 constraint.
+            ValueError: when parameter bounds do not cover zero.
         """
         if parameter.name not in self.parameters:
             raise ValueError(
                 f"The given parameter with name: {parameter.name} cannot "
                 f"be found in the parameter list: {self.parameters}."
             )
+        if parameter.bounds.contains(0.0):
+            raise ValueError(
+                f"The bounds of the given parameter must cover zero but its bounds "
+                f"are ({parameter.bounds.lower}, {parameter.bounds.upper})."
+            )
 
         return Interval(
             lower=self.relative_threshold * parameter.bounds.lower,

From 705cfb092fb1526fc20c631e4d382f72922da28e Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Fri, 17 Jan 2025 15:32:16 +0100
Subject: [PATCH 068/108] Decouple absolute threshold computation from
 parameter class

---
 baybe/constraints/continuous.py        | 42 +++++++++-----------------
 baybe/searchspace/continuous.py        |  2 +-
 baybe/utils/cardinality_constraints.py |  2 +-
 3 files changed, 16 insertions(+), 30 deletions(-)

diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py
index ce70d00c7..25204088e 100644
--- a/baybe/constraints/continuous.py
+++ b/baybe/constraints/continuous.py
@@ -204,46 +204,32 @@ def sample_inactive_parameters(self, batch_size: int = 1) -> list[set[str]]:
 
         return inactive_params
 
-    def get_threshold(self, parameter: NumericalContinuousParameter) -> Interval:
-        """Get the threshold values of a parameter.
+    def get_absolute_thresholds(self, bounds: Interval, /) -> Interval:
+        """Get the absolute thresholds for a given interval.
 
-        This method calculates the thresholds based on the parameter's bounds
-        and the relative threshold.
-
-        Note:
-            Thresholds (lower, upper) are defined below:
-            * If lower < 0 and upper > 0, any value v with lower < v < upper are treated
-            zero;
-            * If lower = 0 and upper > 0, any value v with lower <= v < upper are
-            treated zero;
-            * If lower < 0 and upper = 0, any value v with lower < v <= upper are
-            treated zero.
+        Turns the relative threshold of the constraint into absolute thresholds
+        for the considered interval. That is, for a given interval ``(a, b)`` with
+        ``a <= 0`` and ``b >= 0``, the method returns the interval ``(r*a, r*b)``,
+        where ``r`` is the relative threshold defined by the constraint.
 
         Args:
-            parameter: The parameter object.
+            bounds: The specified interval.
 
         Returns:
-            The lower and upper thresholds.
+            The absolute thresholds represented as an interval.
 
         Raises:
-            ValueError: when parameter_name is not present in parameter list of this
-                constraint.
-            ValueError: when parameter bounds do not cover zero.
+            ValueError: When the specified interval does not contain zero.
         """
-        if parameter.name not in self.parameters:
-            raise ValueError(
-                f"The given parameter with name: {parameter.name} cannot "
-                f"be found in the parameter list: {self.parameters}."
-            )
-        if parameter.bounds.contains(0.0):
+        if not bounds.contains(0.0):
             raise ValueError(
-                f"The bounds of the given parameter must cover zero but its bounds "
-                f"are ({parameter.bounds.lower}, {parameter.bounds.upper})."
+                f"The specified interval must contain zero. "
+                f"Given: {bounds.to_tuple()}."
             )
 
         return Interval(
-            lower=self.relative_threshold * parameter.bounds.lower,
-            upper=self.relative_threshold * parameter.bounds.upper,
+            lower=self.relative_threshold * bounds.lower,
+            upper=self.relative_threshold * bounds.upper,
         )
 
 
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 70493e7e4..14196225c 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -377,7 +377,7 @@ def _enforce_cardinality_constraints_via_assignment(
                     c for c in self.constraints_cardinality if p.name in c.parameters
                 ][0]
                 p_adjusted = activate_parameter(
-                    p, cardinality_constraint_with_p.get_threshold(p)
+                    p, cardinality_constraint_with_p.get_absolute_thresholds(p.bounds)
                 )
             else:
                 p_adjusted = p
diff --git a/baybe/utils/cardinality_constraints.py b/baybe/utils/cardinality_constraints.py
index f93d0ec5c..4ae39912e 100644
--- a/baybe/utils/cardinality_constraints.py
+++ b/baybe/utils/cardinality_constraints.py
@@ -94,7 +94,7 @@ def is_cardinality_fulfilled(
         parameters_in_c = subspace_continuous.get_parameters_by_name(c.parameters)
 
         # Thresholds of parameters that are related to the cardinality constraint
-        thresholds = tuple(c.get_threshold(p) for p in parameters_in_c)
+        thresholds = tuple(c.get_absolute_thresholds(p.bounds) for p in parameters_in_c)
 
         # Count the number of zeros
         n_zeros = count_zeros(thresholds, batch_related_to_c)

From ab77fc5567b3a905812bd9908d7c9b72481cb0fe Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Mon, 10 Feb 2025 11:18:59 +0100
Subject: [PATCH 069/108] Refactor parameter activation utility

---
 baybe/parameters/utils.py | 45 ++++++++++++---------------------------
 1 file changed, 14 insertions(+), 31 deletions(-)

diff --git a/baybe/parameters/utils.py b/baybe/parameters/utils.py
index b6dc18c38..93bae5e4f 100644
--- a/baybe/parameters/utils.py
+++ b/baybe/parameters/utils.py
@@ -7,9 +7,7 @@
 from attrs import evolve
 
 from baybe.parameters.base import Parameter
-from baybe.parameters.numerical import (
-    NumericalContinuousParameter,
-)
+from baybe.parameters.numerical import NumericalContinuousParameter
 from baybe.utils.interval import Interval
 
 _TParameter = TypeVar("_TParameter", bound=Parameter)
@@ -95,26 +93,27 @@ def sort_parameters(parameters: Collection[Parameter]) -> tuple[Parameter, ...]:
 
 
 def activate_parameter(
-    parameter: NumericalContinuousParameter,
-    thresholds: Interval,
+    parameter: NumericalContinuousParameter, thresholds: Interval
 ) -> NumericalContinuousParameter:
-    """Activates a given parameter by moving its bounds away from zero.
+    """Force-activates a given parameter by moving its bounds away from zero.
+
+    A parameter that is trivially active because its value range does not overlap
+    with the considered inactivity interval is unaffected.
 
     Important:
-        Parameters whose ranges include zero but whose bounds do not overlap with the
-        inactive range (i.e. parameters that contain the value zero far from their
-        boundary values) remain unchanged, because the corresponding activated parameter
+        A parameter whose range includes zero but extends beyond the threshold interval
+        on both sides remains unchanged, because the corresponding activated parameter
         would no longer have a continuous value range.
 
     Args:
         parameter: The parameter to be activated.
-        thresholds: The thresholds of the inactive range of the parameter.
+        thresholds: The considered parameter (in)activity thresholds.
 
     Returns:
         A copy of the parameter with adjusted bounds.
 
     Raises:
-        ValueError: If the threshold does not cover zero.
+        ValueError: If the threshold interval does not contain zero.
         ValueError: If the parameter cannot be activated since both its bounds are
             in the inactive range.
     """
@@ -127,28 +126,11 @@ def activate_parameter(
             f"{thresholds.upper}) is given."
         )
 
-    if not parameter.bounds.contains(0.0):
-        raise ValueError(
-            f"The parameter bounds must cover zero but "
-            f"({parameter.bounds.lower}, {parameter.bounds.upper}) is "
-            f"given."
-        )
-
-    # Note that the definition on the boundary (lower/upper threshold) is vague.
-    # The value on the lower/upper boundary is determined as within inactive_range;
-    # while an activated parameter may take this boundary value (lower/upper
-    # threshold). We allow the misuse of boundary in the "in_inactive_range" and it
-    # is just an utils for checking condition. Ultimately, the "key" threshold
-    # boundary appears as a bound of the activated parameter and this is compatible
-    # with the thresholds defined in ContinuousCardinalityConstraint, as long as the
-    # "key" threshold boundary is not zero. The "key" threshold boundary is always
-    # non-zero when the thresholds are inferred from the bounds of this parameter.
-
     def in_inactive_range(x: float) -> bool:
         """Return true when x is within the inactive range."""
         return thresholds.lower <= x <= thresholds.upper
 
-    # When both bounds in inactive range.
+    # When both bounds are in the in inactive range
     if in_inactive_range(lower_bound) and in_inactive_range(upper_bound):
         raise ValueError(
             f"Parameter '{parameter.name}' cannot be set active since its "
@@ -157,7 +139,7 @@ def in_inactive_range(x: float) -> bool:
         )
 
     # When the upper bound is in inactive range, move it to the lower threshold of the
-    # inactive region.
+    # inactive region
     if lower_bound < thresholds.lower and in_inactive_range(upper_bound):
         return evolve(parameter, bounds=(lower_bound, thresholds.lower))
 
@@ -166,5 +148,6 @@ def in_inactive_range(x: float) -> bool:
     if upper_bound > thresholds.upper and in_inactive_range(lower_bound):
         return evolve(parameter, bounds=(thresholds.upper, upper_bound))
 
-    # Both bounds separated from inactive range
+    # When the parameter is already trivially active (or activating it would tear
+    # its value range apart)
     return parameter

From 3911159bd7d4101202bc71fa89824bd901cdccba Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Mon, 10 Feb 2025 11:13:07 +0100
Subject: [PATCH 070/108] Refactor parameter activation test

---
 tests/utils/test_parameters.py | 85 ++++++++--------------------------
 1 file changed, 20 insertions(+), 65 deletions(-)

diff --git a/tests/utils/test_parameters.py b/tests/utils/test_parameters.py
index 2c4a3af8c..04f7519a3 100644
--- a/tests/utils/test_parameters.py
+++ b/tests/utils/test_parameters.py
@@ -1,5 +1,7 @@
 """Tests for parameter utilities."""
 
+from unittest.mock import Mock
+
 import pytest
 from pytest import param
 
@@ -14,91 +16,79 @@ def mirror_interval(interval: Interval) -> Interval:
     return Interval(lower=-interval.upper, upper=-interval.lower)
 
 
+@pytest.mark.parametrize("mirror", [False, True], ids=["regular", "mirrored"])
 @pytest.mark.parametrize(
     (
         "bounds",
         "thresholds",
-        "is_valid",
         "expected_bounds",
     ),
     [
         param(
             Interval(lower=-1.0, upper=1.0),
             Interval(lower=-1.0, upper=1.0),
-            False,
             None,
             id="bounds_on_thresholds",
         ),
         param(
             Interval(lower=-1.0, upper=1.0),
             Interval(lower=-1.5, upper=1.5),
-            False,
             None,
             id="bounds_in_thresholds",
         ),
         param(
             Interval(lower=-1.0, upper=1.0),
             Interval(lower=-1.5, upper=1.0),
-            False,
             None,
-            id="bounds_in_thresholds_single_side_match",
+            id="bounds_in_thresholds_one_side_match",
         ),
         param(
             Interval(lower=-1.0, upper=1.0),
             Interval(lower=-0.5, upper=0.5),
-            True,
             Interval(lower=-1.0, upper=1.0),
             id="thresholds_in_bounds",
         ),
         param(
             Interval(lower=-1.0, upper=1.0),
             Interval(lower=-0.5, upper=1.0),
-            True,
             Interval(lower=-1.0, upper=-0.5),
-            id="thresholds_in_bounds_single_side_match",
+            id="thresholds_in_bounds_one_side_match",
         ),
         param(
             Interval(lower=-0.5, upper=1.0),
             Interval(lower=-1.0, upper=0.5),
-            True,
             Interval(lower=0.5, upper=1.0),
             id="bounds_intersected_with_thresholds",
         ),
         param(
             Interval(lower=0.0, upper=1.0),
             Interval(lower=-1.0, upper=0.0),
-            True,
             Interval(lower=0.0, upper=1.0),
             id="bounds_intersected_with_thresholds_on_one_point",
         ),
     ],
 )
-@pytest.mark.parametrize("mirror", [False, True])
-def test_activate_parameter(
+def test_parameter_activation(
     bounds: Interval,
     thresholds: Interval,
-    is_valid: bool,
     expected_bounds: Interval | None,
     mirror: bool,
-) -> None:
-    """Test that the utility correctly activate a parameter.
+):
+    """The parameter activation utility correctly activates a parameter.
 
     Args:
-        bounds: the bounds of the parameter to activate
-        thresholds: the thresholds of inactive range
-        is_valid: boolean variable indicating whether a parameter is returned from
-            activate_parameter
-        expected_bounds: the bounds of the activated parameter if one is returned
-        mirror: if true both bounds and thresholds get mirrored
-
-    Returns:
-        None
+        bounds: The bounds of the parameter to activate.
+        thresholds: The inactivity thresholds.
+        expected_bounds: The expected bounds of the activated parameter.
+        mirror: If ``True``, both bounds and thresholds get mirrored.
     """
+    is_valid = expected_bounds is not None
+
     if mirror:
         bounds = mirror_interval(bounds)
         thresholds = mirror_interval(thresholds)
-    if mirror and is_valid:
-        expected_bounds = mirror_interval(expected_bounds)
+        if is_valid:
+            expected_bounds = mirror_interval(expected_bounds)
 
     parameter = NumericalContinuousParameter("parameter", bounds=bounds)
 
@@ -112,42 +102,7 @@ def test_activate_parameter(
             activate_parameter(parameter, thresholds)
 
 
-@pytest.mark.parametrize(
-    ("bounds", "thresholds", "match"),
-    [
-        param(
-            Interval(lower=-0.5, upper=0.5),
-            Interval(lower=0.5, upper=1.0),
-            "The thresholds must cover zero",
-            id="invalid_thresholds",
-        ),
-        param(
-            Interval(lower=0.5, upper=1.0),
-            Interval(lower=-0.5, upper=0.5),
-            "The parameter bounds must cover zero",
-            id="invalid_bounds",
-        ),
-    ],
-)
-@pytest.mark.parametrize("mirror", [False, True])
-def test_invalid_activate_parameter(
-    bounds: Interval, thresholds: Interval, match: str, mirror: bool
-) -> None:
-    """Test that invalid bounds or thresholds are given.
-
-    Args:
-        bounds: the bounds of the parameter to activate
-        thresholds: the thresholds of inactive range
-        match: error message to match
-        mirror: if true both bounds and thresholds get mirrored
-
-    Returns:
-        None
-    """
-    if mirror:
-        bounds = mirror_interval(bounds)
-        thresholds = mirror_interval(thresholds)
-
-    parameter = NumericalContinuousParameter("parameter", bounds=bounds)
-    with pytest.raises(ValueError, match=match):
-        activate_parameter(parameter, thresholds)
+def test_invalid_parameter_activation():
+    """Activating a parameter requires a valid inactive region to start with."""
+    with pytest.raises(ValueError, match="The thresholds must cover zero"):
+        activate_parameter(Mock(), Interval(lower=0.5, upper=1.0))

From 86541ea70c665e5f38d2a7f245b2a59b04731876 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Mon, 10 Feb 2025 11:13:23 +0100
Subject: [PATCH 071/108] Add missing test case

---
 tests/utils/test_parameters.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/tests/utils/test_parameters.py b/tests/utils/test_parameters.py
index 04f7519a3..75cc7fc1c 100644
--- a/tests/utils/test_parameters.py
+++ b/tests/utils/test_parameters.py
@@ -66,6 +66,12 @@ def mirror_interval(interval: Interval) -> Interval:
             Interval(lower=0.0, upper=1.0),
             id="bounds_intersected_with_thresholds_on_one_point",
         ),
+        param(
+            Interval(lower=0.5, upper=1.0),
+            Interval(lower=-1.0, upper=0.0),
+            Interval(lower=0.5, upper=1.0),
+            id="bounds_and_thresholds_nonoverlapping",
+        ),
     ],
 )
 def test_parameter_activation(

From 113f6343f7bfdb7b679171a0d5ae6341da37457a Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Mon, 10 Feb 2025 13:09:47 +0100
Subject: [PATCH 072/108] Move utils module to constraints subpackage

---
 .../{utils/cardinality_constraints.py => constraints/utils.py}  | 0
 baybe/recommenders/pure/bayesian/botorch.py                     | 2 +-
 tests/constraints/test_cardinality_constraint_continuous.py     | 2 +-
 3 files changed, 2 insertions(+), 2 deletions(-)
 rename baybe/{utils/cardinality_constraints.py => constraints/utils.py} (100%)

diff --git a/baybe/utils/cardinality_constraints.py b/baybe/constraints/utils.py
similarity index 100%
rename from baybe/utils/cardinality_constraints.py
rename to baybe/constraints/utils.py
diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 4f5a61028..7b6b6bdbf 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -17,6 +17,7 @@
 
 from baybe.acquisition.acqfs import qThompsonSampling
 from baybe.constraints import ContinuousCardinalityConstraint
+from baybe.constraints.utils import is_cardinality_fulfilled
 from baybe.exceptions import (
     IncompatibilityError,
     IncompatibleAcquisitionFunctionError,
@@ -30,7 +31,6 @@
     SubspaceContinuous,
     SubspaceDiscrete,
 )
-from baybe.utils.cardinality_constraints import is_cardinality_fulfilled
 from baybe.utils.dataframe import to_tensor
 from baybe.utils.plotting import to_string
 from baybe.utils.sampling_algorithms import (
diff --git a/tests/constraints/test_cardinality_constraint_continuous.py b/tests/constraints/test_cardinality_constraint_continuous.py
index 5c4780109..c93aa8293 100644
--- a/tests/constraints/test_cardinality_constraint_continuous.py
+++ b/tests/constraints/test_cardinality_constraint_continuous.py
@@ -12,12 +12,12 @@
     ContinuousCardinalityConstraint,
     ContinuousLinearConstraint,
 )
+from baybe.constraints.utils import is_cardinality_fulfilled
 from baybe.exceptions import MinimumCardinalityViolatedWarning
 from baybe.parameters.numerical import NumericalContinuousParameter
 from baybe.recommenders import BotorchRecommender
 from baybe.searchspace.core import SearchSpace, SubspaceContinuous
 from baybe.targets.numerical import NumericalTarget
-from baybe.utils.cardinality_constraints import is_cardinality_fulfilled
 
 
 def _validate_cardinality_constrained_batch(

From 83726cb9111d349b64332cd67889fbdd049d0e07 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Mon, 10 Feb 2025 20:49:06 +0100
Subject: [PATCH 073/108] Refactor cardinality constraint utilities

---
 baybe/constraints/utils.py                    | 116 +++++-------------
 baybe/recommenders/pure/bayesian/botorch.py   |   4 +-
 .../test_cardinality_constraint_continuous.py |   4 +-
 3 files changed, 37 insertions(+), 87 deletions(-)

diff --git a/baybe/constraints/utils.py b/baybe/constraints/utils.py
index 4ae39912e..d46587447 100644
--- a/baybe/constraints/utils.py
+++ b/baybe/constraints/utils.py
@@ -1,6 +1,4 @@
-"""Utilities related to cardinality constraints."""
-
-from typing import Literal
+"""Constraint utilities."""
 
 import numpy as np
 import pandas as pd
@@ -9,105 +7,57 @@
 from baybe.utils.interval import Interval
 
 
-def count_zeros(thresholds: tuple[Interval, ...], points: pd.DataFrame) -> np.ndarray:
-    """Return the counts of zeros in the recommendations.
+def is_between(df: pd.DataFrame, thresholds: dict[str, Interval]) -> pd.DataFrame:
+    """Check if the values of a dataframe lie within column-specific intervals.
 
     Args:
-        thresholds: A list of thresholds according to which the counts of zeros
-            in the recommendations should be calculated.
-        points: The recommendations of the parameter objects.
+        df: A dataframe containing numeric values.
+        thresholds: A dictionary mapping column names to individual intervals.
 
     Returns:
-        The counts of zero parameters in the recommendations.
-
-    Raises:
-        ValueError: If the number of thresholds differs from the number of
-            parameters in points.
+        A Boolean-valued dataframe indicating which elements lie in the intervals.
     """
-    if len(thresholds) != len(points.columns):
-        raise ValueError(
-            f"The size of thresholds ({len(thresholds)}) must be the same as the "
-            f"number of parameters ({len(points.columns)}) in points."
-        )
-    # Get the lower/upper thresholds for determining zeros/non-zeros
-    lower_thresholds = np.array([threshold.lower for threshold in thresholds])
-    lower_thresholds = np.broadcast_to(lower_thresholds, points.shape)
-
-    upper_thresholds = np.array([threshold.upper for threshold in thresholds])
-    upper_thresholds = np.broadcast_to(upper_thresholds, points.shape)
-
-    # Boolean values indicating whether the candidates are treated zeros: True for zero
-    zero_flags = (points > lower_thresholds) & (points < upper_thresholds)
-
-    # Correct the comparison on the special boundary: zero. This step is needed
-    # because when the lower_threshold = 0, a value v with lower_threshold <= v <
-    # upper_threshold should be treated zero.
-    zero_flags = (points == 0.0) | zero_flags
-
-    return np.sum(zero_flags, axis=1)
+    lower_thresholds = np.array([thresholds[p].lower for p in df.columns])
+    upper_thresholds = np.array([thresholds[p].upper for p in df.columns])
+    return (df >= lower_thresholds) & (df <= upper_thresholds)
 
 
 def is_cardinality_fulfilled(
+    df: pd.DataFrame,
     subspace_continuous: SubspaceContinuous,
-    batch: pd.DataFrame,
-    type_cardinality: Literal["min", "max"],
+    *,
+    check_minimum: bool = True,
+    check_maximum: bool = True,
 ) -> bool:
-    """Check whether all minimum (or maximum) cardinality constraints are fulfilled.
+    """Validate cardinality constraints in a dataframe of parameter configurations.
 
     Args:
-        subspace_continuous: The continuous subspace from which candidates are
-            generated.
-        batch: The recommended batch
-        type_cardinality: "min" or "max". "min" indicates all minimum cardinality
-            constraints will be checked; "max" for all maximum cardinality constraints.
+        df: The dataframe to be checked.
+        subspace_continuous: The subspace spanned by the considered parameters.
+        check_minimum: If ``True``, minimum cardinality constraints are validated.
+        check_maximum: If ``True``, maximum cardinality constraints are validated.
 
     Returns:
-        Return "True" if all minimum (or maximum) cardinality constraints are
-        fulfilled; "False" otherwise.
-
-    Raises:
-        ValueError: If type_cardinality is neither "min" nor "max".
+        ``True`` if all cardinality constraints are fulfilled, ``False`` otherwise.
     """
-    if type_cardinality not in ["min", "max"]:
-        raise ValueError(
-            f"Unknown type of cardinality. Only support min or max but "
-            f"{type_cardinality=} is given."
-        )
-
     if len(subspace_continuous.constraints_cardinality) == 0:
         return True
 
     for c in subspace_continuous.constraints_cardinality:
-        # No need to check the redundant cardinality constraints that are
-        # - min_cardinality = 0
-        # - max_cardinality = len(parameters)
-        if (c.min_cardinality == 0) and type_cardinality == "min":
-            continue
-
-        if (c.max_cardinality == len(c.parameters)) and type_cardinality == "max":
-            continue
-
-        # Batch of parameters that are related to cardinality constraint
-        batch_related_to_c = batch[c.parameters]
-
-        # Parameters related to cardinality constraint
-        parameters_in_c = subspace_continuous.get_parameters_by_name(c.parameters)
-
-        # Thresholds of parameters that are related to the cardinality constraint
-        thresholds = tuple(c.get_absolute_thresholds(p.bounds) for p in parameters_in_c)
-
-        # Count the number of zeros
-        n_zeros = count_zeros(thresholds, batch_related_to_c)
-
-        # When any minimum cardinality is violated
-        if type_cardinality == "min" and np.any(
-            len(c.parameters) - n_zeros < c.min_cardinality
-        ):
+        # Get the activity thresholds for all parameters
+        thresholds = {
+            p.name: c.get_absolute_thresholds(p.bounds)
+            for p in subspace_continuous.get_parameters_by_name(c.parameters)
+        }
+
+        # Count the number of active values per dataframe row
+        n_zeros = is_between(df[c.parameters], thresholds).sum(axis=1)
+        n_active = len(c.parameters) - n_zeros
+
+        # Check if cardinality is violated
+        if check_minimum and np.any(n_active < c.min_cardinality):
             return False
-
-        # When any maximum cardinality is violated
-        if type_cardinality == "max" and np.any(
-            len(c.parameters) - n_zeros > c.max_cardinality
-        ):
+        if check_maximum and np.any(n_active > c.max_cardinality):
             return False
+
     return True
diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 7b6b6bdbf..cef6d8c36 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -290,9 +290,9 @@ def _recommend_continuous_with_cardinality_constraints(
 
         # Check if any minimum cardinality constraints are violated
         if not is_cardinality_fulfilled(
-            subspace_continuous,
             pd.DataFrame(points, columns=subspace_continuous.parameter_names),
-            "min",
+            subspace_continuous,
+            check_maximum=False,
         ):
             warnings.warn(
                 "At least one minimum cardinality constraint is violated.",
diff --git a/tests/constraints/test_cardinality_constraint_continuous.py b/tests/constraints/test_cardinality_constraint_continuous.py
index c93aa8293..dce2ec395 100644
--- a/tests/constraints/test_cardinality_constraint_continuous.py
+++ b/tests/constraints/test_cardinality_constraint_continuous.py
@@ -35,11 +35,11 @@ def _validate_cardinality_constrained_batch(
         captured_warnings: A list of captured warnings.
     """
     # Assert that the maximum cardinality constraint is fulfilled
-    assert is_cardinality_fulfilled(subspace_continuous, batch, "max")
+    assert is_cardinality_fulfilled(batch, subspace_continuous, check_minimum=False)
 
     # Check whether the minimum cardinality constraint is fulfilled
     is_min_cardinality_fulfilled = is_cardinality_fulfilled(
-        subspace_continuous, batch, "min"
+        batch, subspace_continuous, check_maximum=False
     )
 
     # A warning must be raised when the minimum cardinality constraint is not fulfilled

From dd73a380e20a48756fc14cbbed35ef7ed03b8683 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Mon, 10 Feb 2025 20:50:06 +0100
Subject: [PATCH 074/108] Move is_between to utils/dataframe

---
 baybe/constraints/utils.py | 17 +----------------
 baybe/utils/dataframe.py   | 16 ++++++++++++++++
 2 files changed, 17 insertions(+), 16 deletions(-)

diff --git a/baybe/constraints/utils.py b/baybe/constraints/utils.py
index d46587447..2c9649edc 100644
--- a/baybe/constraints/utils.py
+++ b/baybe/constraints/utils.py
@@ -4,22 +4,7 @@
 import pandas as pd
 
 from baybe.searchspace import SubspaceContinuous
-from baybe.utils.interval import Interval
-
-
-def is_between(df: pd.DataFrame, thresholds: dict[str, Interval]) -> pd.DataFrame:
-    """Check if the values of a dataframe lie within column-specific intervals.
-
-    Args:
-        df: A dataframe containing numeric values.
-        thresholds: A dictionary mapping column names to individual intervals.
-
-    Returns:
-        A Boolean-valued dataframe indicating which elements lie in the intervals.
-    """
-    lower_thresholds = np.array([thresholds[p].lower for p in df.columns])
-    upper_thresholds = np.array([thresholds[p].upper for p in df.columns])
-    return (df >= lower_thresholds) & (df <= upper_thresholds)
+from baybe.utils.dataframe import is_between
 
 
 def is_cardinality_fulfilled(
diff --git a/baybe/utils/dataframe.py b/baybe/utils/dataframe.py
index d1037b6d0..0b090b231 100644
--- a/baybe/utils/dataframe.py
+++ b/baybe/utils/dataframe.py
@@ -12,6 +12,7 @@
 from baybe.targets.base import Target
 from baybe.targets.binary import BinaryTarget
 from baybe.targets.enum import TargetMode
+from baybe.utils.interval import Interval
 from baybe.utils.numerical import DTypeFloatNumpy
 
 if TYPE_CHECKING:
@@ -661,3 +662,18 @@ def filter_df(
     out.index.name = index_name
 
     return out
+
+
+def is_between(df: pd.DataFrame, thresholds: dict[str, Interval]) -> pd.DataFrame:
+    """Check if the values of a dataframe lie within column-specific intervals.
+
+    Args:
+        df: A dataframe containing numeric values.
+        thresholds: A dictionary mapping column names to individual intervals.
+
+    Returns:
+        A Boolean-valued dataframe indicating which elements lie in the intervals.
+    """
+    lower_thresholds = np.array([thresholds[p].lower for p in df.columns])
+    upper_thresholds = np.array([thresholds[p].upper for p in df.columns])
+    return (df >= lower_thresholds) & (df <= upper_thresholds)

From e3d8a9014f17a80ab00d86f4c0f678b78572aea3 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 11 Feb 2025 08:43:26 +0100
Subject: [PATCH 075/108] Refactor cardinality warning check

---
 .../test_cardinality_constraint_continuous.py | 26 +++++++++----------
 .../test_constraints_continuous.py            |  5 +---
 2 files changed, 14 insertions(+), 17 deletions(-)

diff --git a/tests/constraints/test_cardinality_constraint_continuous.py b/tests/constraints/test_cardinality_constraint_continuous.py
index dce2ec395..53f2d81fd 100644
--- a/tests/constraints/test_cardinality_constraint_continuous.py
+++ b/tests/constraints/test_cardinality_constraint_continuous.py
@@ -1,6 +1,7 @@
 """Tests for the continuous cardinality constraint."""
 
 import warnings
+from collections.abc import Sequence
 from itertools import combinations_with_replacement
 from warnings import WarningMessage
 
@@ -21,16 +22,16 @@
 
 
 def _validate_cardinality_constrained_batch(
-    subspace_continuous: SubspaceContinuous,
     batch: pd.DataFrame,
+    subspace_continuous: SubspaceContinuous,
     batch_size: int,
-    captured_warnings: list[WarningMessage | None],
+    captured_warnings: Sequence[WarningMessage],
 ):
     """Validate that a cardinality-constrained batch fulfills the necessary conditions.
 
     Args:
+        batch: The batch to validate.
         subspace_continuous: The continuous subspace from which to recommend the points.
-        batch: Batch to validate.
         batch_size: The number of points to be recommended.
         captured_warnings: A list of captured warnings.
     """
@@ -43,9 +44,13 @@ def _validate_cardinality_constrained_batch(
     )
 
     # A warning must be raised when the minimum cardinality constraint is not fulfilled
-    if not is_min_cardinality_fulfilled:
-        w_message = "Minimum cardinality constraints are not guaranteed."
-        assert any(str(w.message) == w_message for w in captured_warnings)
+    if is_min_cardinality_fulfilled:
+        assert not captured_warnings
+    else:
+        assert all(
+            issubclass(w.category, MinimumCardinalityViolatedWarning)
+            for w in captured_warnings
+        )
 
     # Assert that we obtain as many samples as requested
     assert batch.shape[0] == batch_size
@@ -106,7 +111,7 @@ def test_sampling_cardinality_constraint(cardinality_bounds: tuple[int, int]):
         samples = subspace_continous.sample_uniform(BATCH_SIZE)
 
     # Assert that the constraint conditions hold
-    _validate_cardinality_constrained_batch(subspace_continous, samples, BATCH_SIZE, w)
+    _validate_cardinality_constrained_batch(samples, subspace_continous, BATCH_SIZE, w)
 
 
 def test_polytope_sampling_with_cardinality_constraint():
@@ -155,12 +160,7 @@ def test_polytope_sampling_with_cardinality_constraint():
         samples = subspace_continous.sample_uniform(BATCH_SIZE)
 
     # Assert that the constraint conditions hold
-    _validate_cardinality_constrained_batch(
-        subspace_continous,
-        samples,
-        BATCH_SIZE,
-        w,
-    )
+    _validate_cardinality_constrained_batch(samples, subspace_continous, BATCH_SIZE, w)
 
     # Assert that linear equality constraint is fulfilled
     assert np.allclose(
diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py
index 182f18da0..a3c5b301a 100644
--- a/tests/constraints/test_constraints_continuous.py
+++ b/tests/constraints/test_constraints_continuous.py
@@ -111,10 +111,7 @@ def test_cardinality_constraint(recommender):
 
     # Assert that the constraint conditions hold
     _validate_cardinality_constrained_batch(
-        searchspace.continuous,
-        recommendation,
-        BATCH_SIZE,
-        w,
+        recommendation, searchspace.continuous, BATCH_SIZE, w
     )
 
 

From 79aa0b68931883ed4cdb1a563c68f070a05f832f Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 11 Feb 2025 09:32:48 +0100
Subject: [PATCH 076/108] Fix docstring

---
 baybe/constraints/continuous.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py
index 25204088e..68ada9026 100644
--- a/baybe/constraints/continuous.py
+++ b/baybe/constraints/continuous.py
@@ -144,7 +144,7 @@ class ContinuousCardinalityConstraint(
     relative_threshold: float = field(
         default=1e-2, converter=float, validator=[gt(0.0), lt(1.0)]
     )
-    """A relative threshold for determining if the value is considered zero."""
+    """A relative threshold for determining if a value is considered zero."""
 
     @property
     def n_inactive_parameter_combinations(self) -> int:

From b478970bd66fe0930bfe8277e16297d3ffc1abc1 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 11 Feb 2025 09:57:25 +0100
Subject: [PATCH 077/108] Fix error handling in case of infeasibility

---
 baybe/exceptions.py                         |  4 ++++
 baybe/recommenders/pure/bayesian/botorch.py | 20 +++++++++++++-------
 2 files changed, 17 insertions(+), 7 deletions(-)

diff --git a/baybe/exceptions.py b/baybe/exceptions.py
index e6cc4561e..036e52a91 100644
--- a/baybe/exceptions.py
+++ b/baybe/exceptions.py
@@ -41,6 +41,10 @@ class IncompatibleArgumentError(IncompatibilityError):
     """An incompatible argument was passed to a callable."""
 
 
+class InfeasibilityError(Exception):
+    """An optimization problem has no feasible solution."""
+
+
 class NotEnoughPointsLeftError(Exception):
     """
     More recommendations are requested than there are viable parameter configurations
diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index e08e708c9..e990d4691 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -21,6 +21,7 @@
 from baybe.exceptions import (
     IncompatibilityError,
     IncompatibleAcquisitionFunctionError,
+    InfeasibilityError,
     MinimumCardinalityViolatedWarning,
 )
 from baybe.parameters.numerical import _FixedNumericalContinuousParameter
@@ -477,18 +478,22 @@ def _optimize_continuous_subspaces(
     ) -> tuple[Tensor, Tensor]:
         """Find the optimum candidates from multiple continuous subspaces.
 
-        **Important**: A subspace without a feasible solution will be ignored
-        silently, and no warning will be raised. This design is intentional to
-        accommodate recommendations with cardinality constraints. Please be mindful
-        of this behavior when invoking this method.
+        Important:
+            Subspaces without feasible solutions will be silently ignored. If none of
+            the subspaces has a feasible solution, an exception will be raised.
 
         Args:
             subspaces: The subspaces to consider for the optimization.
             batch_size: The number of points to be recommended.
 
+        Raises:
+            InfeasibilityError: If none of the subspaces has a feasible solution.
+
         Returns:
             The batch of candidates and the corresponding acquisition value.
         """
+        from botorch.exceptions.errors import InfeasibilityError as BoInfeasibilityError
+
         acqf_values_all: list[Tensor] = []
         points_all: list[Tensor] = []
 
@@ -501,12 +506,13 @@ def _optimize_continuous_subspaces(
                 points_all.append(p)
                 acqf_values_all.append(acqf)
 
-            # TODO: Replace ValueError with customized erorr. See
-            #  https://github.com/pytorch/botorch/pull/2652
             # The optimization problem may be infeasible in certain subspaces
-            except ValueError:
+            except BoInfeasibilityError:
                 pass
 
+        if not points_all:
+            raise InfeasibilityError("No feasible solution could be found.")
+
         # Find the best option f
         best_idx = np.argmax(acqf_values_all)
         points = points_all[best_idx]

From 5a1c26fd9ad509425fdaadba0a152a68f039a9d3 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 11 Feb 2025 11:00:58 +0100
Subject: [PATCH 078/108] Consider other warning types in cardinality warning
 check

---
 .../test_cardinality_constraint_continuous.py       | 13 ++++++-------
 1 file changed, 6 insertions(+), 7 deletions(-)

diff --git a/tests/constraints/test_cardinality_constraint_continuous.py b/tests/constraints/test_cardinality_constraint_continuous.py
index 53f2d81fd..245dc3646 100644
--- a/tests/constraints/test_cardinality_constraint_continuous.py
+++ b/tests/constraints/test_cardinality_constraint_continuous.py
@@ -44,13 +44,12 @@ def _validate_cardinality_constrained_batch(
     )
 
     # A warning must be raised when the minimum cardinality constraint is not fulfilled
-    if is_min_cardinality_fulfilled:
-        assert not captured_warnings
-    else:
-        assert all(
-            issubclass(w.category, MinimumCardinalityViolatedWarning)
-            for w in captured_warnings
-        )
+    cardinality_warnings = [
+        w
+        for w in captured_warnings
+        if issubclass(w.category, MinimumCardinalityViolatedWarning)
+    ]
+    assert is_min_cardinality_fulfilled != bool(cardinality_warnings)
 
     # Assert that we obtain as many samples as requested
     assert batch.shape[0] == batch_size

From f71b394772ad4a4b88d15b5ba2502ce51e7dd2fb Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 11 Feb 2025 11:09:04 +0100
Subject: [PATCH 079/108] Assert that cardinality constraints are disjoint

---
 baybe/searchspace/continuous.py | 15 +++++++++++----
 1 file changed, 11 insertions(+), 4 deletions(-)

diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 14196225c..f01dcd3f2 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -371,16 +371,23 @@ def _enforce_cardinality_constraints_via_assignment(
         for p in self.parameters:
             if p.name in inactive_parameter_names:
                 p_adjusted = _FixedNumericalContinuousParameter(name=p.name, value=0.0)
+
             elif p.name in active_parameter_names:
-                # cardinality constraint object containing the current parameter
-                cardinality_constraint_with_p = [
+                constraints = [
                     c for c in self.constraints_cardinality if p.name in c.parameters
-                ][0]
+                ]
+
+                # Constraint validation should have ensured that each parameter can
+                # be part of at most one cardinality constraint
+                assert len(constraints) == 1
+
                 p_adjusted = activate_parameter(
-                    p, cardinality_constraint_with_p.get_absolute_thresholds(p.bounds)
+                    p, constraints[0].get_absolute_thresholds(p.bounds)
                 )
+
             else:
                 p_adjusted = p
+
             adjusted_parameters.append(p_adjusted)
 
         return evolve(self, parameters=adjusted_parameters, constraints_nonlin=())

From daa594af534637075539dcb0b489a9e5ae7331dc Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 11 Feb 2025 11:10:27 +0100
Subject: [PATCH 080/108] Add temporary botorch workaround

---
 baybe/recommenders/pure/bayesian/botorch.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index e990d4691..c0cc97123 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -502,6 +502,11 @@ def _optimize_continuous_subspaces(
                 # Optimize the acquisition function
                 p, acqf = self._recommend_continuous_torch(subspace, batch_size)
 
+                # TODO: https://github.com/pytorch/botorch/issues/2740
+                if acqf.ndim == 1:
+                    assert len(acqf) == 1
+                    acqf = acqf[0]
+
                 # Append optimization results
                 points_all.append(p)
                 acqf_values_all.append(acqf)

From a14e6e1e5140a0ed7c2e9ab1685d273e35fd371b Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 11 Feb 2025 13:55:58 +0100
Subject: [PATCH 081/108] Refine definition of inactive parameter interval

---
 baybe/constraints/continuous.py | 8 +++++++-
 baybe/constraints/utils.py      | 4 +++-
 baybe/utils/dataframe.py        | 2 +-
 3 files changed, 11 insertions(+), 3 deletions(-)

diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py
index 889951aa9..098510b56 100644
--- a/baybe/constraints/continuous.py
+++ b/baybe/constraints/continuous.py
@@ -144,7 +144,13 @@ class ContinuousCardinalityConstraint(
     relative_threshold: float = field(
         default=1e-2, converter=float, validator=[gt(0.0), lt(1.0)]
     )
-    """A relative threshold for determining if a value is considered zero."""
+    """A relative threshold for determining if a value is considered zero.
+
+    The threshold defines an **open** (asymmetric) interval around zero, because
+    numerical routines that optimize parameter values on the complementary set
+    considered to contain "nonzero" values may push the numerical value exactly to the
+    interval boundary, which should therefore also be considered "nonzero".
+    """
 
     @property
     def n_inactive_parameter_combinations(self) -> int:
diff --git a/baybe/constraints/utils.py b/baybe/constraints/utils.py
index 2c9649edc..fd61c2612 100644
--- a/baybe/constraints/utils.py
+++ b/baybe/constraints/utils.py
@@ -36,7 +36,9 @@ def is_cardinality_fulfilled(
         }
 
         # Count the number of active values per dataframe row
-        n_zeros = is_between(df[c.parameters], thresholds).sum(axis=1)
+        cols = df[c.parameters]
+        is_inactive = is_between(cols, thresholds) | (cols == 0.0)
+        n_zeros = is_inactive.sum(axis=1)
         n_active = len(c.parameters) - n_zeros
 
         # Check if cardinality is violated
diff --git a/baybe/utils/dataframe.py b/baybe/utils/dataframe.py
index f363f0d0c..49c46e671 100644
--- a/baybe/utils/dataframe.py
+++ b/baybe/utils/dataframe.py
@@ -748,4 +748,4 @@ def is_between(df: pd.DataFrame, thresholds: dict[str, Interval]) -> pd.DataFram
     """
     lower_thresholds = np.array([thresholds[p].lower for p in df.columns])
     upper_thresholds = np.array([thresholds[p].upper for p in df.columns])
-    return (df >= lower_thresholds) & (df <= upper_thresholds)
+    return (df > lower_thresholds) & (df < upper_thresholds)

From 1bfaf1355d1adf78ba0c58f40d18ece3a8e49a17 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 11 Feb 2025 15:19:26 +0100
Subject: [PATCH 082/108] Activate parameters only in presence of minimum
 cardinality constraints

---
 baybe/searchspace/continuous.py | 14 ++++++++++----
 1 file changed, 10 insertions(+), 4 deletions(-)

diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index f01dcd3f2..ebbca852e 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -380,10 +380,16 @@ def _enforce_cardinality_constraints_via_assignment(
                 # Constraint validation should have ensured that each parameter can
                 # be part of at most one cardinality constraint
                 assert len(constraints) == 1
-
-                p_adjusted = activate_parameter(
-                    p, constraints[0].get_absolute_thresholds(p.bounds)
-                )
+                constraint = constraints[0]
+
+                # If the corresponding constraint enforces a minimum cardinality,
+                # force-activate the parameter
+                if constraint.min_cardinality > 0:
+                    p_adjusted = activate_parameter(
+                        p, constraint.get_absolute_thresholds(p.bounds)
+                    )
+                else:
+                    p_adjusted = p
 
             else:
                 p_adjusted = p

From 0cc2e9fcc26bade27eda1e391d3406207f075f01 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 11 Feb 2025 15:32:16 +0100
Subject: [PATCH 083/108] Explain inactivity threshold logic in docstring

---
 baybe/constraints/continuous.py | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py
index 098510b56..9c3f03355 100644
--- a/baybe/constraints/continuous.py
+++ b/baybe/constraints/continuous.py
@@ -146,9 +146,12 @@ class ContinuousCardinalityConstraint(
     )
     """A relative threshold for determining if a value is considered zero.
 
-    The threshold defines an **open** (asymmetric) interval around zero, because
-    numerical routines that optimize parameter values on the complementary set
-    considered to contain "nonzero" values may push the numerical value exactly to the
+    The threshold is translated into an asymmetric open interval around zero via
+    :meth:`get_absolute_thresholds`.
+
+    **Note:** The interval induced by the threshold is considered **open** because
+    numerical routines that optimize parameter values on the complementary set (i.e. the
+    value range considered "nonzero") may push the numerical value exactly to the
     interval boundary, which should therefore also be considered "nonzero".
     """
 

From 2a449ca287dde93a69cdb6d4aaaad9b46366a5fc Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 11 Feb 2025 23:13:02 +0100
Subject: [PATCH 084/108] Revert "Add temporary botorch workaround"

This reverts commit daa594af534637075539dcb0b489a9e5ae7331dc.
---
 baybe/recommenders/pure/bayesian/botorch.py | 5 -----
 1 file changed, 5 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index c0cc97123..e990d4691 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -502,11 +502,6 @@ def _optimize_continuous_subspaces(
                 # Optimize the acquisition function
                 p, acqf = self._recommend_continuous_torch(subspace, batch_size)
 
-                # TODO: https://github.com/pytorch/botorch/issues/2740
-                if acqf.ndim == 1:
-                    assert len(acqf) == 1
-                    acqf = acqf[0]
-
                 # Append optimization results
                 points_all.append(p)
                 acqf_values_all.append(acqf)

From 1bbf60594525120e6a870f638be9d7ad75772c62 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 12 Feb 2025 10:09:33 +0100
Subject: [PATCH 085/108] Rename
 _enforce_cardinality_constraints_via_assignment

---
 baybe/recommenders/pure/bayesian/botorch.py | 6 +-----
 baybe/searchspace/continuous.py             | 4 ++--
 2 files changed, 3 insertions(+), 7 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index e990d4691..b22c5ab7a 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -279,11 +279,7 @@ def _recommend_continuous_with_cardinality_constraints(
 
         # Create iterable of subspaces to be optimized
         subspaces = (
-            (
-                subspace_continuous._enforce_cardinality_constraints_via_assignment(
-                    inactive_parameters
-                )
-            )
+            (subspace_continuous._enforce_cardinality_constraints(inactive_parameters))
             for inactive_parameters in iterator
         )
 
diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index ebbca852e..d6bc76a9b 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -342,7 +342,7 @@ def _drop_parameters(self, parameter_names: Collection[str]) -> SubspaceContinuo
             ],
         )
 
-    def _enforce_cardinality_constraints_via_assignment(
+    def _enforce_cardinality_constraints(
         self,
         inactive_parameter_names: Collection[str],
     ) -> SubspaceContinuous:
@@ -549,7 +549,7 @@ def _sample_from_polytope_with_cardinality_constraints(
             # optional and it helps reduce the parameter space with certain
             # computational cost.
             subspace_without_cardinality_constraint = (
-                self._enforce_cardinality_constraints_via_assignment(
+                self._enforce_cardinality_constraints(
                     inactive_params_sample
                 )._drop_parameters(inactive_params_sample)
             )

From 9e776e5bb9224150fdfda8971b1c957dcd6091fd Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 12 Feb 2025 13:12:38 +0100
Subject: [PATCH 086/108] Extend docstring to explain limit on number of
 subspaces

---
 baybe/recommenders/pure/bayesian/botorch.py | 18 ++++++++++++------
 1 file changed, 12 insertions(+), 6 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index b22c5ab7a..f252301b3 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -236,13 +236,19 @@ def _recommend_continuous_with_cardinality_constraints(
     ) -> tuple[Tensor, Tensor]:
         """Recommend from a continuous search space with cardinality constraints.
 
-        This is achieved by considering the individual restricted subspaces that can be
+        This is achieved by considering the individual restricted subspaces that can be
         obtained by splitting the parameters into sets of active and inactive
-        parameters, according to what is allowed by the cardinality constraints. In each
-        of these spaces, the in-/activity assignment is fixed, so that the cardinality
-        constraints can be removed and a regular optimization can be performed. The
-        recommendation is then constructed from the combined optimization results of the
-        unconstrained spaces.
+        parameters, according to what is allowed by the cardinality constraints.
+
+        The specific collection of subspaces considered by the recommender is obtained
+        as either the full combinatorial set of possible parameter splits or a random
+        selection thereof, depending on the upper bound specified by the corresponding
+        recommender attribute.
+
+        In each of these spaces, the (in)activity assignment is fixed, so that the
+        cardinality constraints can be removed and a regular optimization can be
+        performed. The recommendation is then constructed from the combined optimization
+        results of the unconstrained spaces.
 
         Args:
             subspace_continuous: The continuous subspace from which to generate

From 2e37b3bd13f2d32d6332dd62538b4626f2e57705 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 12 Feb 2025 13:24:33 +0100
Subject: [PATCH 087/108] Add a recommended action to the cardinality error
 message

---
 baybe/constraints/validation.py | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/baybe/constraints/validation.py b/baybe/constraints/validation.py
index 8c80a172f..7d04defe0 100644
--- a/baybe/constraints/validation.py
+++ b/baybe/constraints/validation.py
@@ -147,7 +147,13 @@ def validate_cardinality_constraint_parameter_bounds(
                     f"The bounds of all parameters affected by a constraint of type "
                     f"'{ContinuousCardinalityConstraint.__name__}' must include zero, "
                     f"but the bounds of parameter '{name}' are: "
-                    f"{parameter.bounds.to_tuple()}"
+                    f"{parameter.bounds.to_tuple()}, which may indicate unintended "
+                    f"settings in your parameter definition. "
+                    f"A parameter whose value range includes zero trivially "
+                    f"increases the cardinality of the resulting configuration by one. "
+                    f"Therefore, if your parameter definitions are all correct, "
+                    f"consider excluding the parameter from the constraint and "
+                    f"reducing the cardinality limits by one accordingly."
                 )
             )
 

From 207367f7d6d3955c06da2cdc9bd90e66d8b53f06 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 12 Feb 2025 13:54:02 +0100
Subject: [PATCH 088/108] Update CHANGELOG.md

---
 CHANGELOG.md | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 296618233..9119d3ca4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,15 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Stored benchmarking results now include the Python environment and version
 - `qPSTD` acquisition function
 - `ContinuousCardinalityConstraint` is now compatible with `BotorchRecommender`
-- Warning `MinimumCardinalityViolatedWarning` is triggered when any minimum 
-  cardinality is violated in `BotorchRecommender`
+- A `MinimumCardinalityViolatedWarning` is triggered when minimum cardinality
+  constraints are violated
 - Attribute `max_n_subspaces` to `BotorchRecommender`, allowing to control
-  optimization behavior in the presence of multiple subspaces
+  optimization behavior in the presence of cardinality constraints
 - Utilities `inactive_parameter_combinations` and`n_inactive_parameter_combinations` 
   in both `ContinuousCardinalityConstraint`and `SubspaceContinuous`
-- Attribute `relative_threshold` and method `get_threshold` to 
+- Attribute `relative_threshold` and method `get_absolute_thresholds` to 
   `ContinuousCardinalityConstraint`
-- Utilities `count_zeros` and `is_cardinality_fulfilled`
+- Utilities `activate_parameter`, `is_between` and `is_cardinality_fulfilled`
 
 ### Changed
 - Acquisition function indicator `is_mc` has been removed in favor of new indicators 

From 627698dd65ebd55ca1c6a7c02bcd55fa4deb5c21 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 25 Feb 2025 11:40:42 +0100
Subject: [PATCH 089/108] Drop unnecessary guard clause

---
 baybe/constraints/utils.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/baybe/constraints/utils.py b/baybe/constraints/utils.py
index fd61c2612..1d3360cc6 100644
--- a/baybe/constraints/utils.py
+++ b/baybe/constraints/utils.py
@@ -25,9 +25,6 @@ def is_cardinality_fulfilled(
     Returns:
         ``True`` if all cardinality constraints are fulfilled, ``False`` otherwise.
     """
-    if len(subspace_continuous.constraints_cardinality) == 0:
-        return True
-
     for c in subspace_continuous.constraints_cardinality:
         # Get the activity thresholds for all parameters
         thresholds = {

From e0c5e682c3c96ec85e22e2ff197899a68889fae4 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Tue, 25 Feb 2025 11:43:13 +0100
Subject: [PATCH 090/108] Fix error message

---
 baybe/constraints/validation.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/baybe/constraints/validation.py b/baybe/constraints/validation.py
index 7d04defe0..51a1a7a91 100644
--- a/baybe/constraints/validation.py
+++ b/baybe/constraints/validation.py
@@ -146,10 +146,10 @@ def validate_cardinality_constraint_parameter_bounds(
                 ValueError(
                     f"The bounds of all parameters affected by a constraint of type "
                     f"'{ContinuousCardinalityConstraint.__name__}' must include zero, "
-                    f"but the bounds of parameter '{name}' are: "
+                    f"but the bounds of parameter '{name}' are "
                     f"{parameter.bounds.to_tuple()}, which may indicate unintended "
                     f"settings in your parameter definition. "
-                    f"A parameter whose value range includes zero trivially "
+                    f"A parameter whose value range excludes zero trivially "
                     f"increases the cardinality of the resulting configuration by one. "
                     f"Therefore, if your parameter definitions are all correct, "
                     f"consider excluding the parameter from the constraint and "

From 7d091c7a497b58355a54f9af5ce9c14c3fb7c483 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 26 Feb 2025 09:33:59 +0100
Subject: [PATCH 091/108] Change default threshold value from 1e-2 to 1e-3

---
 baybe/constraints/continuous.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py
index 9c3f03355..f3d14b444 100644
--- a/baybe/constraints/continuous.py
+++ b/baybe/constraints/continuous.py
@@ -142,7 +142,7 @@ class ContinuousCardinalityConstraint(
     """Class for continuous cardinality constraints."""
 
     relative_threshold: float = field(
-        default=1e-2, converter=float, validator=[gt(0.0), lt(1.0)]
+        default=1e-3, converter=float, validator=[gt(0.0), lt(1.0)]
     )
     """A relative threshold for determining if a value is considered zero.
 

From 9cae97b0c28753940e2dee7d3a3293eeca9426d4 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 26 Feb 2025 09:37:36 +0100
Subject: [PATCH 092/108] Refine error message

Co-authored-by: Martin Fitzner <17951239+Scienfitz@users.noreply.github.com>
---
 baybe/parameters/utils.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/baybe/parameters/utils.py b/baybe/parameters/utils.py
index 93bae5e4f..0a9a956a8 100644
--- a/baybe/parameters/utils.py
+++ b/baybe/parameters/utils.py
@@ -123,7 +123,7 @@ def activate_parameter(
     if not thresholds.contains(0.0):
         raise ValueError(
             f"The thresholds must cover zero but ({thresholds.lower}, "
-            f"{thresholds.upper}) is given."
+            f"{thresholds.upper}) was given."
         )
 
     def in_inactive_range(x: float) -> bool:

From 430483bcc2f3afe4b2c6e4f8d47fb9e3d5904737 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 26 Feb 2025 10:31:58 +0100
Subject: [PATCH 093/108] Fix docstring

Co-authored-by: Martin Fitzner <17951239+Scienfitz@users.noreply.github.com>
---
 baybe/recommenders/pure/bayesian/botorch.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index f252301b3..d1d4026ba 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -219,7 +219,7 @@ def _recommend_continuous(
     def _recommend_continuous_torch(
         self, subspace_continuous: SubspaceContinuous, batch_size: int
     ) -> tuple[Tensor, Tensor]:
-        """Dispatcher selecting continuous optimization routine."""
+        """Dispatcher selecting the continuous optimization routine."""
         if subspace_continuous.constraints_cardinality:
             return self._recommend_continuous_with_cardinality_constraints(
                 subspace_continuous, batch_size

From d002e2184f187f879bc671753df3760b5962b1a8 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 26 Feb 2025 09:44:43 +0100
Subject: [PATCH 094/108] Catch more specific InfeasibilityError instead of
 generic ValueError

---
 baybe/searchspace/continuous.py | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index d6bc76a9b..4d0989936 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -531,6 +531,7 @@ def _sample_from_polytope_with_cardinality_constraints(
                 f"Use '{SubspaceContinuous._sample_from_bounds.__name__}' "
                 f"or '{SubspaceContinuous._sample_from_polytope.__name__}' instead."
             )
+        from botorch.exceptions.errors import InfeasibilityError
 
         # List to store the created samples
         samples: list[pd.DataFrame] = []
@@ -554,13 +555,11 @@ def _sample_from_polytope_with_cardinality_constraints(
                 )._drop_parameters(inactive_params_sample)
             )
 
-            # TODO: Replace ValueError with customized erorr. See
-            #  https://github.com/pytorch/botorch/pull/2652
             # Sample from the reduced space
             try:
                 sample = subspace_without_cardinality_constraint.sample_uniform(1)
                 samples.append(sample)
-            except ValueError:
+            except InfeasibilityError:
                 n_fails += 1
 
             # Avoid infinite loop

From 8f2c98ff522588701b12e977c6c902d9a0b2be38 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 26 Feb 2025 10:30:09 +0100
Subject: [PATCH 095/108] Refine function definition and docstring

---
 baybe/parameters/utils.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/baybe/parameters/utils.py b/baybe/parameters/utils.py
index 0a9a956a8..4a3e4b4c6 100644
--- a/baybe/parameters/utils.py
+++ b/baybe/parameters/utils.py
@@ -126,8 +126,8 @@ def activate_parameter(
             f"{thresholds.upper}) was given."
         )
 
-    def in_inactive_range(x: float) -> bool:
-        """Return true when x is within the inactive range."""
+    def in_inactive_range(x: float, /) -> bool:
+        """Return whether the argument is within the inactive range."""
         return thresholds.lower <= x <= thresholds.upper
 
     # When both bounds are in the in inactive range

From 067b12e3bd557647437959c17e300a8c8429891d Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 26 Feb 2025 10:48:41 +0100
Subject: [PATCH 096/108] Change return type from tuple to frozenset

---
 baybe/searchspace/continuous.py | 12 +++++++-----
 1 file changed, 7 insertions(+), 5 deletions(-)

diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 4d0989936..c60bfeded 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -305,10 +305,10 @@ def comp_rep_columns(self) -> tuple[str, ...]:
         return tuple(chain.from_iterable(p.comp_rep_columns for p in self.parameters))
 
     @property
-    def parameter_names_in_cardinality_constraints(self) -> tuple[str, ...]:
+    def parameter_names_in_cardinality_constraints(self) -> frozenset[str]:
         """The names of all parameters affected by cardinality constraints."""
         names_per_constraint = (c.parameters for c in self.constraints_cardinality)
-        return tuple(chain(*names_per_constraint))
+        return frozenset(chain(*names_per_constraint))
 
     @property
     def comp_rep_bounds(self) -> pd.DataFrame:
@@ -361,9 +361,11 @@ def _enforce_cardinality_constraints(
             constraints.
         """
         # Extract active parameters involved in cardinality constraints
-        active_parameter_names = set(
-            self.parameter_names_in_cardinality_constraints
-        ).difference(inactive_parameter_names)
+        active_parameter_names = (
+            self.parameter_names_in_cardinality_constraints.difference(
+                inactive_parameter_names
+            )
+        )
 
         # Adjust parameters depending on their in-/activity assignment
         adjusted_parameters: list[ContinuousParameter] = []

From 336efadf7859aa342f1d091ed5a0557ddc017e6d Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 26 Feb 2025 10:57:41 +0100
Subject: [PATCH 097/108] Move cardinality constraint test to dedicated module

---
 .../test_cardinality_constraint_continuous.py | 38 ++++++++++++++-
 .../test_constraints_continuous.py            | 47 -------------------
 2 files changed, 37 insertions(+), 48 deletions(-)

diff --git a/tests/constraints/test_cardinality_constraint_continuous.py b/tests/constraints/test_cardinality_constraint_continuous.py
index 245dc3646..ee06f4cd9 100644
--- a/tests/constraints/test_cardinality_constraint_continuous.py
+++ b/tests/constraints/test_cardinality_constraint_continuous.py
@@ -17,7 +17,9 @@
 from baybe.exceptions import MinimumCardinalityViolatedWarning
 from baybe.parameters.numerical import NumericalContinuousParameter
 from baybe.recommenders import BotorchRecommender
-from baybe.searchspace.core import SearchSpace, SubspaceContinuous
+from baybe.recommenders.pure.bayesian.base import BayesianRecommender
+from baybe.recommenders.pure.nonpredictive.sampling import RandomRecommender
+from baybe.searchspace import SearchSpace, SubspaceContinuous
 from baybe.targets.numerical import NumericalTarget
 
 
@@ -267,3 +269,37 @@ def test_empty_constraints_after_cardinality_constraint():
     ]
     subspace = SubspaceContinuous.from_product(parameters, constraints)
     subspace.sample_uniform(1)
+
+
+@pytest.mark.parametrize("recommender", [RandomRecommender(), BotorchRecommender()])
+def test_cardinality_constraint(recommender):
+    """Cardinality constraints are taken into account by the recommender."""
+    MIN_CARDINALITY = 4
+    MAX_CARDINALITY = 7
+    BATCH_SIZE = 10
+
+    parameters = [NumericalContinuousParameter(str(i), (0, 1)) for i in range(10)]
+    constraints = [
+        ContinuousCardinalityConstraint(
+            [p.name for p in parameters], MIN_CARDINALITY, MAX_CARDINALITY
+        )
+    ]
+    searchspace = SearchSpace.from_product(parameters, constraints)
+
+    if isinstance(recommender, BayesianRecommender):
+        objective = NumericalTarget("t", "MAX").to_objective()
+        measurements = pd.DataFrame(searchspace.continuous.sample_uniform(2))
+        measurements["t"] = np.random.random(len(measurements))
+    else:
+        objective = None
+        measurements = None
+
+    with warnings.catch_warnings(record=True) as w:
+        recommendation = recommender.recommend(
+            BATCH_SIZE, searchspace, objective, measurements
+        )
+
+    # Assert that the constraint conditions hold
+    _validate_cardinality_constrained_batch(
+        recommendation, searchspace.continuous, BATCH_SIZE, w
+    )
diff --git a/tests/constraints/test_constraints_continuous.py b/tests/constraints/test_constraints_continuous.py
index a3c5b301a..46adc4b21 100644
--- a/tests/constraints/test_constraints_continuous.py
+++ b/tests/constraints/test_constraints_continuous.py
@@ -1,24 +1,11 @@
 """Test for imposing continuous constraints."""
 
-import warnings
-
 import numpy as np
-import pandas as pd
 import pytest
 from pytest import param
 
 from baybe.constraints import ContinuousLinearConstraint
-from baybe.constraints.continuous import ContinuousCardinalityConstraint
-from baybe.parameters.numerical import NumericalContinuousParameter
-from baybe.recommenders.pure.bayesian.base import BayesianRecommender
-from baybe.recommenders.pure.bayesian.botorch import BotorchRecommender
-from baybe.recommenders.pure.nonpredictive.sampling import RandomRecommender
-from baybe.searchspace import SearchSpace
-from baybe.targets.numerical import NumericalTarget
 from tests.conftest import run_iterations
-from tests.constraints.test_cardinality_constraint_continuous import (
-    _validate_cardinality_constrained_batch,
-)
 
 
 @pytest.mark.parametrize("parameter_names", [["Conti_finite1", "Conti_finite2"]])
@@ -81,40 +68,6 @@ 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("recommender", [RandomRecommender(), BotorchRecommender()])
-def test_cardinality_constraint(recommender):
-    """Cardinality constraints are taken into account by the recommender."""
-    MIN_CARDINALITY = 4
-    MAX_CARDINALITY = 7
-    BATCH_SIZE = 10
-
-    parameters = [NumericalContinuousParameter(str(i), (0, 1)) for i in range(10)]
-    constraints = [
-        ContinuousCardinalityConstraint(
-            [p.name for p in parameters], MIN_CARDINALITY, MAX_CARDINALITY
-        )
-    ]
-    searchspace = SearchSpace.from_product(parameters, constraints)
-
-    if isinstance(recommender, BayesianRecommender):
-        objective = NumericalTarget("t", "MAX").to_objective()
-        measurements = pd.DataFrame(searchspace.continuous.sample_uniform(2))
-        measurements["t"] = np.random.random(len(measurements))
-    else:
-        objective = None
-        measurements = None
-
-    with warnings.catch_warnings(record=True) as w:
-        recommendation = recommender.recommend(
-            BATCH_SIZE, searchspace, objective, measurements
-        )
-
-    # Assert that the constraint conditions hold
-    _validate_cardinality_constrained_batch(
-        recommendation, searchspace.continuous, BATCH_SIZE, w
-    )
-
-
 @pytest.mark.slow
 @pytest.mark.parametrize(
     "parameter_names",

From dd8078f85670ceb5653bf55ff30d8868a63149af Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 26 Feb 2025 10:59:42 +0100
Subject: [PATCH 098/108] Drop blank line

Co-authored-by: Martin Fitzner <17951239+Scienfitz@users.noreply.github.com>
---
 baybe/searchspace/continuous.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index c60bfeded..01f061b89 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -392,7 +392,6 @@ def _enforce_cardinality_constraints(
                     )
                 else:
                     p_adjusted = p
-
             else:
                 p_adjusted = p
 

From fa9c33c897132eff1f4e1c1077993d1470ee904a Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 26 Feb 2025 12:13:41 +0100
Subject: [PATCH 099/108] Explain active parameters in function docstring

---
 baybe/parameters/utils.py | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/baybe/parameters/utils.py b/baybe/parameters/utils.py
index 4a3e4b4c6..855c17a55 100644
--- a/baybe/parameters/utils.py
+++ b/baybe/parameters/utils.py
@@ -97,8 +97,11 @@ def activate_parameter(
 ) -> NumericalContinuousParameter:
     """Force-activates a given parameter by moving its bounds away from zero.
 
-    A parameter that is trivially active because its value range does not overlap
-    with the considered inactivity interval is unaffected.
+    A parameter is considered active if its value falls outside the specified threshold
+    interval. Force-activating a parameter adjusts its range to ensure it cannot take
+    values within this interval. Parameters that are inherently active, due to their
+    original value ranges not overlapping with the inactivity interval, remain
+    unchanged.
 
     Important:
         A parameter whose range includes zero but extends beyond the threshold interval

From 2287c3d928bd68b5a4cddaeca878885b712b6ede Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 26 Feb 2025 12:18:02 +0100
Subject: [PATCH 100/108] Fix dropping of cardinality constraints

---
 baybe/searchspace/continuous.py | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py
index 01f061b89..27ed05e32 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -397,7 +397,15 @@ def _enforce_cardinality_constraints(
 
             adjusted_parameters.append(p_adjusted)
 
-        return evolve(self, parameters=adjusted_parameters, constraints_nonlin=())
+        return evolve(
+            self,
+            parameters=adjusted_parameters,
+            constraints_nonlin=[
+                c
+                for c in self.constraints_nonlin
+                if not isinstance(c, ContinuousCardinalityConstraint)
+            ],
+        )
 
     def transform(
         self,

From 96308ad205dcda25280f383a63b2ebad7637a2c6 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Wed, 26 Feb 2025 12:43:05 +0100
Subject: [PATCH 101/108] Refine changelog entries

---
 CHANGELOG.md | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9119d3ca4..07a3f0a87 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,10 +15,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Attribute `max_n_subspaces` to `BotorchRecommender`, allowing to control
   optimization behavior in the presence of cardinality constraints
 - Utilities `inactive_parameter_combinations` and`n_inactive_parameter_combinations` 
-  in both `ContinuousCardinalityConstraint`and `SubspaceContinuous`
+  to both `ContinuousCardinalityConstraint`and `SubspaceContinuous` for iterating
+  over cardinality-constrained parameter sets
 - Attribute `relative_threshold` and method `get_absolute_thresholds` to 
-  `ContinuousCardinalityConstraint`
-- Utilities `activate_parameter`, `is_between` and `is_cardinality_fulfilled`
+  `ContinuousCardinalityConstraint` for handling inactivity ranges
+- Utilities `activate_parameter` and `is_cardinality_fulfilled` for enforcing and
+  validating cardinality constraints
+- Utility `is_between` as a dataframe-compatible version of `pandas.Series.between`
 
 ### Changed
 - Acquisition function indicator `is_mc` has been removed in favor of new indicators 

From df181ad292eeb4bdfbdb85a8e0a16ddab5658d39 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Thu, 27 Feb 2025 10:22:27 +0100
Subject: [PATCH 102/108] Move assert statement out of context block

---
 .../constraints/test_cardinality_constraint_continuous.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/tests/constraints/test_cardinality_constraint_continuous.py b/tests/constraints/test_cardinality_constraint_continuous.py
index ee06f4cd9..a3ddd8404 100644
--- a/tests/constraints/test_cardinality_constraint_continuous.py
+++ b/tests/constraints/test_cardinality_constraint_continuous.py
@@ -232,10 +232,10 @@ def prepare_measurements() -> pd.DataFrame:
         BotorchRecommender().recommend(
             BATCH_SIZE, searchspace, objective, prepare_measurements()
         )
-        assert any(
-            issubclass(w.category, MinimumCardinalityViolatedWarning)
-            for w in captured_warnings
-        )
+    assert any(
+        issubclass(w.category, MinimumCardinalityViolatedWarning)
+        for w in captured_warnings
+    )
 
 
 def test_empty_constraints_after_cardinality_constraint():

From 710b6e79c97b591e103241636f0967c9d9a25178 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Thu, 27 Feb 2025 10:26:36 +0100
Subject: [PATCH 103/108] Explain reason for minimum cardinality violation in
 warning

---
 baybe/recommenders/pure/bayesian/botorch.py | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index d1d4026ba..d2156c942 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -298,7 +298,11 @@ def _recommend_continuous_with_cardinality_constraints(
             check_maximum=False,
         ):
             warnings.warn(
-                "At least one minimum cardinality constraint is violated.",
+                "At least one minimum cardinality constraint has been violated. "
+                "This may occur when parameter ranges extend beyond zero in both "
+                "directions, making the feasible region non-convex. For such "
+                "parameters, minimum cardinality constraints are currently not "
+                "enforced due to the complexity of the resulting optimization problem.",
                 MinimumCardinalityViolatedWarning,
             )
 

From 509c81e7896ab0ef9136d8d582411bba7c2e1613 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Thu, 27 Feb 2025 10:34:13 +0100
Subject: [PATCH 104/108] Add note explaining the explicit constraint
 conversion step

---
 baybe/recommenders/pure/bayesian/botorch.py | 15 ++++++++++-----
 1 file changed, 10 insertions(+), 5 deletions(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index d2156c942..8e5c6707e 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -342,21 +342,22 @@ def _recommend_continuous_without_cardinality_constraints(
             if isinstance(p, _FixedNumericalContinuousParameter)
         }
 
+        # NOTE: The explicit `or None` conversion is added as an additional safety net
+        #   because it is unclear if the corresponding presence checks for these
+        #   arguments is correctly implemented in all invoked BoTorch subroutines.
+        #   For details: https://github.com/pytorch/botorch/issues/2042
         points, acqf_values = optimize_acqf(
             acq_function=self._botorch_acqf,
             bounds=torch.from_numpy(subspace_continuous.comp_rep_bounds.values),
             q=batch_size,
             num_restarts=self.n_restarts,
             raw_samples=self.n_raw_samples,
-            # TODO: https://github.com/pytorch/botorch/issues/2042
             fixed_features=fixed_parameters or None,
-            # TODO: https://github.com/pytorch/botorch/issues/2042
             equality_constraints=[
                 c.to_botorch(subspace_continuous.parameters)
                 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)
                 for c in subspace_continuous.constraints_lin_ineq
@@ -428,6 +429,10 @@ def _recommend_hybrid(
         fixed_features_list = candidates_comp.to_dict("records")
 
         # Actual call of the BoTorch optimization routine
+        # NOTE: The explicit `or None` conversion is added as an additional safety net
+        #   because it is unclear if the corresponding presence checks for these
+        #   arguments is correctly implemented in all invoked BoTorch subroutines.
+        #   For details: https://github.com/pytorch/botorch/issues/2042
         points, _ = optimize_acqf_mixed(
             acq_function=self._botorch_acqf,
             bounds=torch.from_numpy(searchspace.comp_rep_bounds.values),
@@ -442,7 +447,7 @@ def _recommend_hybrid(
                 )
                 for c in searchspace.continuous.constraints_lin_eq
             ]
-            or None,  # TODO: https://github.com/pytorch/botorch/issues/2042
+            or None,
             inequality_constraints=[
                 c.to_botorch(
                     searchspace.continuous.parameters,
@@ -450,7 +455,7 @@ def _recommend_hybrid(
                 )
                 for c in searchspace.continuous.constraints_lin_ineq
             ]
-            or None,  # TODO: https://github.com/pytorch/botorch/issues/2042
+            or None,
         )
 
         # Align candidates with search space index. Done via including the search space

From 50c019ff1d78f57160fcbd003e0355dd2da67194 Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Thu, 27 Feb 2025 10:41:22 +0100
Subject: [PATCH 105/108] Fix return type annotation

---
 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 27ed05e32..1664c64d4 100644
--- a/baybe/searchspace/continuous.py
+++ b/baybe/searchspace/continuous.py
@@ -5,7 +5,7 @@
 import gc
 import math
 import warnings
-from collections.abc import Collection, Iterable, Sequence
+from collections.abc import Collection, Iterator, Sequence
 from itertools import chain, product
 from typing import TYPE_CHECKING, Any, cast
 
@@ -148,7 +148,7 @@ def n_inactive_parameter_combinations(self) -> int:
             c.n_inactive_parameter_combinations for c in self.constraints_cardinality
         )
 
-    def inactive_parameter_combinations(self) -> Iterable[frozenset[str]]:
+    def inactive_parameter_combinations(self) -> Iterator[frozenset[str]]:
         """Get an iterator over all possible combinations of inactive parameters."""
         for combination in product(
             *[

From 12e44219ddb0b7c65752d2b543847901c628e07a Mon Sep 17 00:00:00 2001
From: AdrianSosic <adrian.sosic@merckgroup.com>
Date: Thu, 27 Feb 2025 10:57:32 +0100
Subject: [PATCH 106/108] Add suggestion to InfeasibilityError message

---
 baybe/recommenders/pure/bayesian/botorch.py | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py
index 8e5c6707e..a6c92de41 100644
--- a/baybe/recommenders/pure/bayesian/botorch.py
+++ b/baybe/recommenders/pure/bayesian/botorch.py
@@ -522,7 +522,13 @@ def _optimize_continuous_subspaces(
                 pass
 
         if not points_all:
-            raise InfeasibilityError("No feasible solution could be found.")
+            raise InfeasibilityError(
+                "No feasible solution could be found. Potentially the specified "
+                "constraints are too restrictive, i.e. there may be too many "
+                "constraints or thresholds may have been set too tightly. "
+                "Considered relaxing the constraints to improve the chances "
+                "of finding a feasible solution."
+            )
 
         # Find the best option f
         best_idx = np.argmax(acqf_values_all)

From fa20ddbe5c56972995e33da0502928571082827b Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Fri, 28 Feb 2025 17:12:33 +0100
Subject: [PATCH 107/108] Define "in_inactive_range" for checking if value is
 in inactive range

---
 CHANGELOG.md               |  1 -
 baybe/constraints/utils.py | 10 +++++--
 baybe/parameters/utils.py  | 57 ++++++++++++++++++++++++++++++++++----
 baybe/utils/dataframe.py   | 16 -----------
 4 files changed, 58 insertions(+), 26 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07a3f0a87..42cc00e3e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,7 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   `ContinuousCardinalityConstraint` for handling inactivity ranges
 - Utilities `activate_parameter` and `is_cardinality_fulfilled` for enforcing and
   validating cardinality constraints
-- Utility `is_between` as a dataframe-compatible version of `pandas.Series.between`
 
 ### Changed
 - Acquisition function indicator `is_mc` has been removed in favor of new indicators 
diff --git a/baybe/constraints/utils.py b/baybe/constraints/utils.py
index 1d3360cc6..ad9357ba1 100644
--- a/baybe/constraints/utils.py
+++ b/baybe/constraints/utils.py
@@ -3,8 +3,8 @@
 import numpy as np
 import pandas as pd
 
+from baybe.parameters.utils import in_inactive_range
 from baybe.searchspace import SubspaceContinuous
-from baybe.utils.dataframe import is_between
 
 
 def is_cardinality_fulfilled(
@@ -26,15 +26,19 @@ def is_cardinality_fulfilled(
         ``True`` if all cardinality constraints are fulfilled, ``False`` otherwise.
     """
     for c in subspace_continuous.constraints_cardinality:
+        cols = df[c.parameters]
         # Get the activity thresholds for all parameters
         thresholds = {
             p.name: c.get_absolute_thresholds(p.bounds)
             for p in subspace_continuous.get_parameters_by_name(c.parameters)
         }
+        lower_thresholds = np.array([thresholds[p].lower for p in cols.columns])
+        upper_thresholds = np.array([thresholds[p].upper for p in cols.columns])
 
         # Count the number of active values per dataframe row
-        cols = df[c.parameters]
-        is_inactive = is_between(cols, thresholds) | (cols == 0.0)
+        is_inactive = in_inactive_range(
+            cols.to_numpy(), lower_thresholds, upper_thresholds
+        )
         n_zeros = is_inactive.sum(axis=1)
         n_active = len(c.parameters) - n_zeros
 
diff --git a/baybe/parameters/utils.py b/baybe/parameters/utils.py
index 855c17a55..7479c0468 100644
--- a/baybe/parameters/utils.py
+++ b/baybe/parameters/utils.py
@@ -1,8 +1,10 @@
 """Parameter utilities."""
 
 from collections.abc import Callable, Collection
+from functools import partial
 from typing import Any, TypeVar
 
+import numpy as np
 import pandas as pd
 from attrs import evolve
 
@@ -129,12 +131,15 @@ def activate_parameter(
             f"{thresholds.upper}) was given."
         )
 
-    def in_inactive_range(x: float, /) -> bool:
-        """Return whether the argument is within the inactive range."""
-        return thresholds.lower <= x <= thresholds.upper
+    # Callable checking whether the argument is within the inactive range
+    _in_inactive_range = partial(
+        in_inactive_range,
+        lower_threshold=thresholds.lower,
+        upper_threshold=thresholds.upper,
+    )
 
     # When both bounds are in the in inactive range
-    if in_inactive_range(lower_bound) and in_inactive_range(upper_bound):
+    if _in_inactive_range(lower_bound) and _in_inactive_range(upper_bound):
         raise ValueError(
             f"Parameter '{parameter.name}' cannot be set active since its "
             f"bounds {parameter.bounds.to_tuple()} are entirely contained in the "
@@ -143,14 +148,54 @@ def in_inactive_range(x: float, /) -> bool:
 
     # When the upper bound is in inactive range, move it to the lower threshold of the
     # inactive region
-    if lower_bound < thresholds.lower and in_inactive_range(upper_bound):
+    if not _in_inactive_range(lower_bound) and _in_inactive_range(upper_bound):
         return evolve(parameter, bounds=(lower_bound, thresholds.lower))
 
     # When the lower bound is in inactive range, move it to the upper threshold of
     # the inactive region
-    if upper_bound > thresholds.upper and in_inactive_range(lower_bound):
+    if not _in_inactive_range(upper_bound) and _in_inactive_range(lower_bound):
         return evolve(parameter, bounds=(thresholds.upper, upper_bound))
 
     # When the parameter is already trivially active (or activating it would tear
     # its value range apart)
     return parameter
+
+
+def in_inactive_range(
+    x: np.ndarray | float,
+    lower_threshold: np.ndarray | float,
+    upper_threshold: np.ndarray | float,
+) -> np.ndarray:
+    """Check if the values can be treated zero or inactive.
+
+    Args:
+        x: A numpy array containing numeric values.
+        lower_threshold: Lower threshold of inactive region.
+        upper_threshold: Upper threshold of inactive region.
+
+    Returns:
+        A Boolean-valued numpy array indicating which elements are inactive.
+
+    Raises:
+        TypeError: If input arguments are not of the same type.
+        TypeError: If the types of input arguments are neither np.array nor float.
+    """
+    error_message = (
+        f"All input arguments must be of the same type: float or numpy "
+        f"array: but arguments of type {type(x)}, "
+        f"{type(lower_threshold)} and {type(upper_threshold)} are given."
+    )
+
+    if len({type(x), type(lower_threshold), type(upper_threshold)}) > 1:
+        raise TypeError(error_message)
+    if not (isinstance(x, np.ndarray) or isinstance(x, float)):
+        raise TypeError(error_message)
+
+    # When none of the inactive range thresholds lie on 0.0, the inactive range is an
+    # open interval: (lower_threshold, upper_threshold). This means a value x is
+    # treated inactive when it is in the exclusive inactive range (lower_threshold,
+    # upper_threshold). When any threshold of the inactive range lies on 0.0,
+    # the inactive range is a half-open, half-close interval. E.g. when the
+    # lower_threshold is 0.0, the inactive range is [0,0, upper_threshold).
+    is_inactive = ((x > lower_threshold) & (x < upper_threshold)) | (x == 0.0)
+    return is_inactive
diff --git a/baybe/utils/dataframe.py b/baybe/utils/dataframe.py
index 49c46e671..61cc4f051 100644
--- a/baybe/utils/dataframe.py
+++ b/baybe/utils/dataframe.py
@@ -13,7 +13,6 @@
 from baybe.targets.base import Target
 from baybe.targets.binary import BinaryTarget
 from baybe.targets.enum import TargetMode
-from baybe.utils.interval import Interval
 from baybe.utils.numerical import DTypeFloatNumpy
 
 if TYPE_CHECKING:
@@ -734,18 +733,3 @@ def wrapper(df: pd.DataFrame, /) -> pd.DataFrame:
         return wrapper
 
     return decorator
-
-
-def is_between(df: pd.DataFrame, thresholds: dict[str, Interval]) -> pd.DataFrame:
-    """Check if the values of a dataframe lie within column-specific intervals.
-
-    Args:
-        df: A dataframe containing numeric values.
-        thresholds: A dictionary mapping column names to individual intervals.
-
-    Returns:
-        A Boolean-valued dataframe indicating which elements lie in the intervals.
-    """
-    lower_thresholds = np.array([thresholds[p].lower for p in df.columns])
-    upper_thresholds = np.array([thresholds[p].upper for p in df.columns])
-    return (df > lower_thresholds) & (df < upper_thresholds)

From b880d8e411d23a57b7de6578e8edca9d4c9b87b1 Mon Sep 17 00:00:00 2001
From: Di Jin <di.jin@merckgroup.com>
Date: Fri, 28 Feb 2025 21:20:46 +0100
Subject: [PATCH 108/108] Capture fixed parameter scenario in
 "activate_parameter" and fix test

---
 baybe/parameters/utils.py      | 13 +++++++++++-
 tests/utils/test_parameters.py | 37 +++++++++++++++++++++++++++++-----
 2 files changed, 44 insertions(+), 6 deletions(-)

diff --git a/baybe/parameters/utils.py b/baybe/parameters/utils.py
index 7479c0468..2876a58de 100644
--- a/baybe/parameters/utils.py
+++ b/baybe/parameters/utils.py
@@ -9,7 +9,10 @@
 from attrs import evolve
 
 from baybe.parameters.base import Parameter
-from baybe.parameters.numerical import NumericalContinuousParameter
+from baybe.parameters.numerical import (
+    NumericalContinuousParameter,
+    _FixedNumericalContinuousParameter,
+)
 from baybe.utils.interval import Interval
 
 _TParameter = TypeVar("_TParameter", bound=Parameter)
@@ -149,11 +152,19 @@ def activate_parameter(
     # When the upper bound is in inactive range, move it to the lower threshold of the
     # inactive region
     if not _in_inactive_range(lower_bound) and _in_inactive_range(upper_bound):
+        if lower_bound == thresholds.lower:
+            return _FixedNumericalContinuousParameter(
+                name=parameter.name, value=lower_bound
+            )
         return evolve(parameter, bounds=(lower_bound, thresholds.lower))
 
     # When the lower bound is in inactive range, move it to the upper threshold of
     # the inactive region
     if not _in_inactive_range(upper_bound) and _in_inactive_range(lower_bound):
+        if upper_bound == thresholds.upper:
+            return _FixedNumericalContinuousParameter(
+                name=parameter.name, value=upper_bound
+            )
         return evolve(parameter, bounds=(thresholds.upper, upper_bound))
 
     # When the parameter is already trivially active (or activating it would tear
diff --git a/tests/utils/test_parameters.py b/tests/utils/test_parameters.py
index 75cc7fc1c..3384b39f5 100644
--- a/tests/utils/test_parameters.py
+++ b/tests/utils/test_parameters.py
@@ -24,11 +24,20 @@ def mirror_interval(interval: Interval) -> Interval:
         "expected_bounds",
     ),
     [
+        # Depending on whether a threshold lies on zero or not, the inactive range
+        # can be a half-closed or open interval. To capture call possible scenarios, we
+        # consider both with_zero(/nonzero)_threshold cases when necessary.
         param(
             Interval(lower=-1.0, upper=1.0),
             Interval(lower=-1.0, upper=1.0),
-            None,
-            id="bounds_on_thresholds",
+            Interval(lower=-1.0, upper=1.0),
+            id="bounds_on_thresholds_with_nonzero_threshold",
+        ),
+        param(
+            Interval(lower=0.0, upper=1.0),
+            Interval(lower=0.0, upper=1.0),
+            Interval(lower=1.0, upper=1.0),
+            id="bounds_on_thresholds_with_zero_threshold",
         ),
         param(
             Interval(lower=-1.0, upper=1.0),
@@ -39,8 +48,14 @@ def mirror_interval(interval: Interval) -> Interval:
         param(
             Interval(lower=-1.0, upper=1.0),
             Interval(lower=-1.5, upper=1.0),
+            Interval(lower=1.0, upper=1.0),
+            id="bounds_in_thresholds_one_side_match_with_nonzero_threshold",
+        ),
+        param(
+            Interval(lower=-1.0, upper=0.0),
+            Interval(lower=-1.5, upper=0.0),
             None,
-            id="bounds_in_thresholds_one_side_match",
+            id="bounds_in_thresholds_one_side_match_with_zero_threshold",
         ),
         param(
             Interval(lower=-1.0, upper=1.0),
@@ -51,8 +66,14 @@ def mirror_interval(interval: Interval) -> Interval:
         param(
             Interval(lower=-1.0, upper=1.0),
             Interval(lower=-0.5, upper=1.0),
+            Interval(lower=-1.0, upper=1.0),
+            id="thresholds_in_bounds_one_side_match_with_nonzero_threshold",
+        ),
+        param(
+            Interval(lower=-1.0, upper=0.0),
+            Interval(lower=-0.5, upper=0.0),
             Interval(lower=-1.0, upper=-0.5),
-            id="thresholds_in_bounds_one_side_match",
+            id="thresholds_in_bounds_one_side_match_with_zero_threshold",
         ),
         param(
             Interval(lower=-0.5, upper=1.0),
@@ -60,11 +81,17 @@ def mirror_interval(interval: Interval) -> Interval:
             Interval(lower=0.5, upper=1.0),
             id="bounds_intersected_with_thresholds",
         ),
+        param(
+            Interval(lower=0.5, upper=1.0),
+            Interval(lower=-1.0, upper=0.5),
+            Interval(lower=0.5, upper=1.0),
+            id="bounds_intersected_with_thresholds_on_nonzero_one_point",
+        ),
         param(
             Interval(lower=0.0, upper=1.0),
             Interval(lower=-1.0, upper=0.0),
             Interval(lower=0.0, upper=1.0),
-            id="bounds_intersected_with_thresholds_on_one_point",
+            id="bounds_intersected_with_thresholds_on_zero_one_point",
         ),
         param(
             Interval(lower=0.5, upper=1.0),