diff --git a/CHANGELOG.md b/CHANGELOG.md index d9931d0d0e..de8520f2f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 instead of a single tuple (needed for interpoint constraints) ### Fixed +- `ContinuousCardinalityConstraint` now works in hybrid search spaces +- Typo in `_FixedNumericalContinuousParameter` where `is_numeric` was used + instead of `is_numerical` - `SHAPInsight` breaking with `numpy>=2.4` due to no longer accepted implicit array to scalar conversion diff --git a/baybe/constraints/utils.py b/baybe/constraints/utils.py index 22570f29b8..6d5e1e7378 100644 --- a/baybe/constraints/utils.py +++ b/baybe/constraints/utils.py @@ -3,6 +3,7 @@ import numpy as np import pandas as pd +from baybe.constraints.continuous import ContinuousCardinalityConstraint from baybe.parameters.utils import is_inactive from baybe.searchspace import SubspaceContinuous @@ -25,7 +26,12 @@ def is_cardinality_fulfilled( Returns: ``True`` if all cardinality constraints are fulfilled, ``False`` otherwise. """ - for c in subspace_continuous.constraints_cardinality: + cardinality_constraints = [ + c + for c in subspace_continuous.constraints_subspace_generating + if isinstance(c, ContinuousCardinalityConstraint) + ] + for c in cardinality_constraints: # Get the activity thresholds for all parameters cols = df[c.parameters] thresholds = { diff --git a/baybe/parameters/numerical.py b/baybe/parameters/numerical.py index ba210de244..418d7b2598 100644 --- a/baybe/parameters/numerical.py +++ b/baybe/parameters/numerical.py @@ -155,7 +155,7 @@ def summary(self) -> dict: class _FixedNumericalContinuousParameter(ContinuousParameter): """Parameter class for fixed numerical parameters.""" - is_numeric: ClassVar[bool] = True + is_numerical: ClassVar[bool] = True # See base class. value: float = field(converter=float) diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py index 0f89b1f80f..79b1a11410 100644 --- a/baybe/recommenders/pure/bayesian/botorch.py +++ b/baybe/recommenders/pure/bayesian/botorch.py @@ -5,7 +5,7 @@ import gc import math import warnings -from collections.abc import Collection, Iterable +from collections.abc import Callable, Collection, Iterable from typing import TYPE_CHECKING, Any, ClassVar import numpy as np @@ -16,7 +16,6 @@ from typing_extensions import override from baybe.acquisition.acqfs import qThompsonSampling -from baybe.constraints import ContinuousCardinalityConstraint from baybe.constraints.utils import is_cardinality_fulfilled from baybe.exceptions import ( IncompatibilityError, @@ -91,11 +90,10 @@ class BotorchRecommender(BayesianRecommender): """ 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.""" + """Maximum number of subspaces to evaluate when subspace-generating constraints are + present (e.g., continuous cardinality constraints). If the total number of subspaces + exceeds this limit, a random subset of that size is sampled for optimization instead + of performing an exhaustive search.""" @sampling_percentage.validator def _validate_percentage( # noqa: DOC101, DOC103 @@ -138,6 +136,34 @@ def _recommend_discrete( ) -> pd.Index: """Generate recommendations from a discrete search space. + Dispatches to the appropriate optimization routine depending on whether + subspace-generating constraints are present. Currently, no discrete + constraints generate subspaces, so this always routes to + ``_recommend_discrete_without_subspaces``. + + Args: + subspace_discrete: The discrete subspace from which to generate + recommendations. + candidates_exp: The experimental representation of all discrete candidate + points to be considered. + batch_size: The size of the recommendation batch. + + Returns: + The dataframe indices of the recommended points in the provided + experimental representation. + """ + return self._recommend_discrete_without_subspaces( + subspace_discrete, candidates_exp, batch_size + ) + + def _recommend_discrete_without_subspaces( + self, + subspace_discrete: SubspaceDiscrete, + candidates_exp: pd.DataFrame, + batch_size: int, + ) -> pd.Index: + """Generate recommendations from a discrete search space. + Args: subspace_discrete: The discrete subspace from which to generate recommendations. @@ -227,35 +253,34 @@ def _recommend_continuous_torch( self, subspace_continuous: SubspaceContinuous, batch_size: int ) -> tuple[Tensor, Tensor]: """Dispatcher selecting the continuous optimization routine.""" - if subspace_continuous.constraints_cardinality: - return self._recommend_continuous_with_cardinality_constraints( + if subspace_continuous.constraints_subspace_generating: + return self._recommend_continuous_with_subspaces( subspace_continuous, batch_size ) else: - return self._recommend_continuous_without_cardinality_constraints( + return self._recommend_continuous_without_subspaces( subspace_continuous, batch_size ) - def _recommend_continuous_with_cardinality_constraints( + def _recommend_continuous_with_subspaces( self, subspace_continuous: SubspaceContinuous, batch_size: int, ) -> tuple[Tensor, Tensor]: - """Recommend from a continuous search space with cardinality constraints. + """Recommend from a continuous space with subspace-generating 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. + Optimizes the acquisition function across subspaces defined by constraints + (currently only cardinality constraints) and returns the best result. 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. + In each subspace, the constraint-imposed configuration is fixed, so that the + 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 @@ -266,37 +291,47 @@ def _recommend_continuous_with_cardinality_constraints( The recommendations and corresponding acquisition values. Raises: - ValueError: If the continuous search space has no cardinality constraints. + ValueError: If the continuous search space has no subspace-generating + constraints. """ - if not subspace_continuous.constraints_cardinality: + if not subspace_continuous.constraints_subspace_generating: raise ValueError( - f"'{self._recommend_continuous_with_cardinality_constraints.__name__}' " - f"expects a subspace with constraints of type " - f"'{ContinuousCardinalityConstraint.__name__}'. " + f"'{self._recommend_continuous_with_subspaces.__name__}' " + f"expects a subspace with subspace-generating constraints." ) - # Determine search scope based on number of inactive parameter combinations - exhaustive_search = ( - subspace_continuous.n_inactive_parameter_combinations - <= self.max_n_subspaces - ) - iterator: Iterable[Collection[str]] - if exhaustive_search: - # If manageable, evaluate all combinations of inactive parameters - iterator = subspace_continuous.inactive_parameter_combinations() + # Determine search scope based on number of subspace configurations + configs: Iterable[frozenset[str]] + if subspace_continuous.n_subspaces <= self.max_n_subspaces: + configs = subspace_continuous.subspace_configurations() else: - # Otherwise, draw a random subset of inactive parameter combinations - iterator = subspace_continuous._sample_inactive_parameters( + configs = subspace_continuous._sample_subspace_configurations( self.max_n_subspaces ) - # Create iterable of subspaces to be optimized - subspaces = ( - (subspace_continuous._enforce_cardinality_constraints(inactive_parameters)) - for inactive_parameters in iterator - ) + # Create closures for each subspace configuration + def make_callable( + inactive_params: Collection[str], + ) -> Callable[[], tuple[Tensor, Tensor]]: + def optimize() -> tuple[Tensor, Tensor]: + import torch - points, acqf_value = self._optimize_continuous_subspaces(subspaces, batch_size) + sub = subspace_continuous._enforce_cardinality_constraints( + inactive_params + ) + # Note: We explicitly evaluate the acqf function for the batch + # because the object returned by the optimization routine may + # contain joint or individual acquisition values, depending on + # whether sequential or joint optimization is applied + p, _ = self._recommend_continuous_torch(sub, batch_size) + with torch.no_grad(): + acqf_value = self._botorch_acqf(p) + return p, acqf_value + + return optimize + + callables = (make_callable(ip) for ip in configs) + points, acqf_value = self._optimize_over_subspaces(callables) # Check if any minimum cardinality constraints are violated if not is_cardinality_fulfilled( @@ -315,12 +350,12 @@ def _recommend_continuous_with_cardinality_constraints( return points, acqf_value - def _recommend_continuous_without_cardinality_constraints( + def _recommend_continuous_without_subspaces( self, subspace_continuous: SubspaceContinuous, batch_size: int, ) -> tuple[Tensor, Tensor]: - """Recommend from a continuous search space without cardinality constraints. + """Recommend from a continuous search space without subspace decomposition. Args: subspace_continuous: The continuous subspace from which to generate @@ -331,16 +366,16 @@ def _recommend_continuous_without_cardinality_constraints( The recommendations and corresponding acquisition values. Raises: - ValueError: If the continuous search space has cardinality constraints. + ValueError: If the continuous search space has subspace-generating + constraints. """ import torch from botorch.optim import optimize_acqf - if subspace_continuous.constraints_cardinality: + if subspace_continuous.constraints_subspace_generating: raise ValueError( - f"'{self._recommend_continuous_without_cardinality_constraints.__name__}' " # noqa: E501 - f"expects a subspace without constraints of type " - f"'{ContinuousCardinalityConstraint.__name__}'. " + f"'{self._recommend_continuous_without_subspaces.__name__}' " + f"expects a subspace without subspace-generating constraints." ) fixed_parameters = { @@ -399,6 +434,34 @@ def _recommend_hybrid( searchspace: SearchSpace, candidates_exp: pd.DataFrame, batch_size: int, + ) -> pd.DataFrame: + """Generate recommendations from a hybrid search space. + + Dispatches to the appropriate optimization routine depending on whether + the continuous part contains subspace-generating constraints. + + Args: + searchspace: The search space in which the recommendations should be made. + candidates_exp: The experimental representation of the candidates + of the discrete subspace. + batch_size: The size of the calculated batch. + + Returns: + The recommended points. + """ + if searchspace.continuous.constraints_subspace_generating: + return self._recommend_hybrid_with_subspaces( + searchspace, candidates_exp, batch_size + ) + return self._recommend_hybrid_without_subspaces( + searchspace, candidates_exp, batch_size + ) + + def _recommend_hybrid_without_subspaces( + self, + searchspace: SearchSpace, + candidates_exp: pd.DataFrame, + batch_size: int, ) -> pd.DataFrame: """Recommend points using the ``optimize_acqf_mixed`` function of BoTorch. @@ -522,65 +585,125 @@ def _recommend_hybrid( return rec_exp - def _optimize_continuous_subspaces( - self, subspaces: Iterable[SubspaceContinuous], batch_size: int - ) -> tuple[Tensor, Tensor]: - """Find the optimum candidates from multiple continuous subspaces. + def _recommend_hybrid_with_subspaces( + self, + searchspace: SearchSpace, + candidates_exp: pd.DataFrame, + batch_size: int, + ) -> pd.DataFrame: + """Recommend from a hybrid space with subspace-generating constraints. - Important: - Subspaces without feasible solutions will be silently ignored. If none of - the subspaces has a feasible solution, an exception will be raised. + Creates subspaces by enumerating/sampling inactive parameter configurations + for the continuous part, then runs hybrid optimization per subspace via + ``_recommend_hybrid_without_subspaces``. Args: - subspaces: The subspaces to consider for the optimization. - batch_size: The number of points to be recommended. + searchspace: The search space in which the recommendations should be made. + candidates_exp: The experimental representation of the candidates + of the discrete subspace. + batch_size: The size of the calculated batch. + + Returns: + The recommended points. + """ + from attrs import evolve + + subspace_c = searchspace.continuous + + # Determine exhaustive vs. sampling + configs: Iterable[frozenset[str]] + if subspace_c.n_subspaces <= self.max_n_subspaces: + configs = subspace_c.subspace_configurations() + else: + configs = subspace_c._sample_subspace_configurations(self.max_n_subspaces) + + def make_callable( + inactive_params: Collection[str], + ) -> Callable[[], tuple[pd.DataFrame, Tensor]]: + def optimize() -> tuple[pd.DataFrame, Tensor]: + import torch + + modified_cont = subspace_c._enforce_cardinality_constraints( + inactive_params + ) + modified_searchspace = evolve(searchspace, continuous=modified_cont) + rec = self._recommend_hybrid_without_subspaces( + modified_searchspace, candidates_exp, batch_size + ) + # Evaluate joint acquisition value on the recommended points + comp = modified_searchspace.transform(rec) + with torch.no_grad(): + acqf_value = self._botorch_acqf(to_tensor(comp.values).unsqueeze(0)) + return rec, acqf_value + + return optimize + + callables = (make_callable(ip) for ip in configs) + best_rec, _ = self._optimize_over_subspaces(callables) + + # Post-check minimum cardinality on continuous columns + if not is_cardinality_fulfilled( + best_rec[list(subspace_c.parameter_names)], + subspace_c, + check_maximum=False, + ): + warnings.warn( + "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, + ) + + return best_rec + + def _optimize_over_subspaces( + self, + subspace_callables: Iterable[Callable[[], tuple[Any, Tensor]]], + ) -> tuple[Any, Tensor]: + """Optimize across subspaces and return the result with the best acqf value. + + Each callable performs optimization for one subspace configuration and returns + a ``(result, acquisition_value)`` tuple. Subspaces that raise + ``InfeasibilityError`` are silently skipped. + + Args: + subspace_callables: An iterable of zero-argument callables. Each callable + runs the optimization for one subspace and returns + ``(result, acqf_value)``. It may raise ``InfeasibilityError`` if the + subspace is infeasible. Raises: InfeasibilityError: If none of the subspaces has a feasible solution. Returns: - The batch of candidates and the corresponding acquisition value. + The result and acquisition value of the best subspace. """ - import torch from botorch.exceptions.errors import InfeasibilityError as BoInfeasibilityError + results_all: list = [] acqf_values_all: list[Tensor] = [] - points_all: list[Tensor] = [] - for subspace in subspaces: + for optimize_fn in subspace_callables: try: - # Optimize the acquisition function - # Note: We explicitly evaluate the acqf function for the batch because - # the object returned by the optimization routine may contain joint or - # individual acquisition values, depending on the whether sequential - # or joint optimization is applied - p, _ = self._recommend_continuous_torch(subspace, batch_size) - with torch.no_grad(): - acqf = self._botorch_acqf(p) - - # Append optimization results - points_all.append(p) - acqf_values_all.append(acqf) - - # The optimization problem may be infeasible in certain subspaces - except BoInfeasibilityError: + result, acqf_value = optimize_fn() + results_all.append(result) + acqf_values_all.append(acqf_value) + except (BoInfeasibilityError, InfeasibilityError): pass - if not points_all: + if not results_all: 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 " + "Consider relaxing the constraints to improve the chances " "of finding a feasible solution." ) - # 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_value + return results_all[best_idx], acqf_values_all[best_idx] # Collect leftover original slotted classes processed by `attrs.define` diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index a3e0fa34f6..256a700ae9 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -108,8 +108,10 @@ def __str__(self) -> str: return to_string(self.__class__.__name__, *fields) @property - def constraints_cardinality(self) -> tuple[ContinuousCardinalityConstraint, ...]: - """Cardinality constraints.""" + def constraints_subspace_generating( + self, + ) -> tuple[ContinuousCardinalityConstraint, ...]: + """Constraints generating subspaces for separate optimization.""" return tuple( c for c in self.constraints_nonlin @@ -143,18 +145,19 @@ def _validate_constraints_lin_ineq( ) @property - def n_inactive_parameter_combinations(self) -> int: - """The number of possible inactive parameter combinations.""" + def n_subspaces(self) -> int: + """The number of possible subspace configurations.""" return math.prod( - c.n_inactive_parameter_combinations for c in self.constraints_cardinality + c.n_inactive_parameter_combinations + for c in self.constraints_subspace_generating ) - def inactive_parameter_combinations(self) -> Iterator[frozenset[str]]: - """Get an iterator over all possible combinations of inactive parameters.""" + def subspace_configurations(self) -> Iterator[frozenset[str]]: + """Get an iterator over all possible subspace configurations.""" for combination in product( *[ con.inactive_parameter_combinations() - for con in self.constraints_cardinality + for con in self.constraints_subspace_generating ] ): yield frozenset(chain(*combination)) @@ -164,10 +167,10 @@ def _validate_constraints_nonlin(self, _, __) -> None: """Validate nonlinear constraints.""" # Note: The passed constraints are accessed indirectly through the property validate_cardinality_constraints_are_nonoverlapping( - self.constraints_cardinality + self.constraints_subspace_generating ) - for con in self.constraints_cardinality: + for con in self.constraints_subspace_generating: validate_cardinality_constraint_parameter_bounds(con, self.parameters) def to_searchspace(self) -> SearchSpace: @@ -306,9 +309,11 @@ 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) -> frozenset[str]: - """The names of all parameters affected by cardinality constraints.""" - names_per_constraint = (c.parameters for c in self.constraints_cardinality) + def parameter_names_in_subspace_constraints(self) -> frozenset[str]: + """The names of all parameters affected by subspace-generating constraints.""" + names_per_constraint = ( + c.parameters for c in self.constraints_subspace_generating + ) return frozenset(chain(*names_per_constraint)) @property @@ -386,7 +391,7 @@ def _enforce_cardinality_constraints( """ # Extract active parameters involved in cardinality constraints active_parameter_names = ( - self.parameter_names_in_cardinality_constraints.difference( + self.parameter_names_in_subspace_constraints.difference( inactive_parameter_names ) ) @@ -400,7 +405,9 @@ def _enforce_cardinality_constraints( elif p.name in active_parameter_names: constraints = [ - c for c in self.constraints_cardinality if p.name in c.parameters + c + for c in self.constraints_subspace_generating + if p.name in c.parameters ] # Constraint validation should have ensured that each parameter can @@ -476,7 +483,7 @@ def sample_uniform(self, batch_size: int = 1) -> pd.DataFrame: if not self.is_constrained: return self._sample_from_bounds(batch_size, self.comp_rep_bounds.values) - if len(self.constraints_cardinality) == 0: + if len(self.constraints_subspace_generating) == 0: return self._sample_from_polytope(batch_size, self.comp_rep_bounds.values) return self._sample_from_polytope_with_cardinality_constraints(batch_size) @@ -562,7 +569,7 @@ def _sample_from_polytope_with_cardinality_constraints( self, batch_size: int ) -> pd.DataFrame: """Draw random samples from a polytope with cardinality constraints.""" - if not self.constraints_cardinality: + if not self.constraints_subspace_generating: raise RuntimeError( f"This method should not be called without any constraints of type " f"'{ContinuousCardinalityConstraint.__name__}' in place. " @@ -579,7 +586,7 @@ def _sample_from_polytope_with_cardinality_constraints( while len(samples) < batch_size: # Randomly set some parameters inactive - inactive_params_sample = self._sample_inactive_parameters(1)[0] + inactive_params_sample = self._sample_subspace_configurations(1)[0] # Remove the inactive parameters from the search space. In the first # step, the active parameters get activated and inactive parameters are @@ -617,13 +624,15 @@ def _sample_from_polytope_with_cardinality_constraints( .fillna(0.0) ) - def _sample_inactive_parameters(self, batch_size: int = 1) -> list[set[str]]: - """Sample inactive parameters according to the given cardinality constraints.""" + def _sample_subspace_configurations( + self, batch_size: int = 1 + ) -> list[frozenset[str]]: + """Sample subspace configurations according to the given constraints.""" inactives_per_constraint = [ con.sample_inactive_parameters(batch_size) - for con in self.constraints_cardinality + for con in self.constraints_subspace_generating ] - return [set(chain(*x)) for x in zip(*inactives_per_constraint)] + return [frozenset(chain(*x)) for x in zip(*inactives_per_constraint)] def sample_from_full_factorial(self, batch_size: int = 1) -> pd.DataFrame: """Draw parameter configurations from the full factorial of the space. diff --git a/tests/constraints/test_cardinality_constraint_continuous.py b/tests/constraints/test_cardinality_constraint_continuous.py index 2717ceff42..f69113b75f 100644 --- a/tests/constraints/test_cardinality_constraint_continuous.py +++ b/tests/constraints/test_cardinality_constraint_continuous.py @@ -65,9 +65,12 @@ 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 + cardinality_constraints = [ + c + for c in subspace_continuous.constraints_subspace_generating + if isinstance(c, ContinuousCardinalityConstraint) ] + max_cardinalities = [c.max_cardinality for c in cardinality_constraints] if len(unique_row := batch.drop_duplicates()) == 1: assert (unique_row.iloc[0] == 0.0).all() and all( max_cardinality == 0 for max_cardinality in max_cardinalities diff --git a/tests/constraints/test_cardinality_constraint_hybrid.py b/tests/constraints/test_cardinality_constraint_hybrid.py new file mode 100644 index 0000000000..bcbe115e58 --- /dev/null +++ b/tests/constraints/test_cardinality_constraint_hybrid.py @@ -0,0 +1,88 @@ +"""Tests for cardinality constraints in hybrid search spaces.""" + +import pytest + +from baybe.constraints.continuous import ContinuousCardinalityConstraint +from baybe.constraints.discrete import DiscreteCardinalityConstraint +from baybe.constraints.utils import is_cardinality_fulfilled +from baybe.parameters.numerical import ( + NumericalContinuousParameter, + NumericalDiscreteParameter, +) +from baybe.recommenders import BotorchRecommender +from baybe.searchspace import SearchSpace +from baybe.targets import NumericalTarget +from baybe.utils.dataframe import create_fake_input + +BATCH_SIZE = 2 +MAX_CARDINALITY = 1 + +_discrete_params = [ + NumericalDiscreteParameter(f"d{i}", values=(0.0, 0.5, 1.0)) for i in range(2) +] +_continuous_params = [ + NumericalContinuousParameter(f"c{i}", bounds=(0, 1)) for i in range(2) +] + + +@pytest.mark.parametrize( + ("disc_params", "conti_params", "constraints"), + [ + pytest.param( + [NumericalDiscreteParameter("d", values=(0.0, 1.0))], + _continuous_params, + [ + ContinuousCardinalityConstraint( + parameters=[p.name for p in _continuous_params], + max_cardinality=MAX_CARDINALITY, + ) + ], + id="conti", + ), + pytest.param( + _discrete_params, + [NumericalContinuousParameter("c", bounds=(0, 1))], + [ + DiscreteCardinalityConstraint( + parameters=[p.name for p in _discrete_params], + max_cardinality=MAX_CARDINALITY, + ) + ], + id="disc", + ), + pytest.param( + _discrete_params, + _continuous_params, + [ + DiscreteCardinalityConstraint( + parameters=[p.name for p in _discrete_params], + max_cardinality=MAX_CARDINALITY, + ), + ContinuousCardinalityConstraint( + parameters=[p.name for p in _continuous_params], + max_cardinality=MAX_CARDINALITY, + ), + ], + id="hybrid", + ), + ], +) +def test_cardinality_constraint_hybrid(disc_params, conti_params, constraints): + """Cardinality constraints are respected in hybrid search spaces.""" + parameters = [*disc_params, *conti_params] + searchspace = SearchSpace.from_product(parameters, constraints) + target = NumericalTarget("t") + measurements = create_fake_input(parameters, [target]) + + rec = BotorchRecommender().recommend( + BATCH_SIZE, searchspace, target.to_objective(), measurements + ) + + for c in constraints: + if isinstance(c, ContinuousCardinalityConstraint): + assert is_cardinality_fulfilled( + rec, searchspace.continuous, check_minimum=False + ) + elif isinstance(c, DiscreteCardinalityConstraint): + n_nonzero = (rec[list(c.parameters)] != 0.0).sum(axis=1) + assert (n_nonzero <= c.max_cardinality).all()