diff --git a/CHANGELOG.md b/CHANGELOG.md index 2110cd97c..8fcfc5a5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Validators for `Campaign` attributes _ `_optional` subpackage for managing optional dependencies - Acquisition function for active learning: `qNIPV` +- Abstract `ContinuousNonlinearConstraint` class +- `ContinuousCardinalityConstraint` class and corresponding uniform sampling mechanism ### Changed - Passing an `Objective` to `Campaign` is now optional @@ -26,6 +28,7 @@ _ `_optional` subpackage for managing optional dependencies - Restrict upper versions of main dependencies, motivated by major `numpy` release - Sampling methods in `qNIPV` and `BotorchRecommender` are now specified via `DiscreteSamplingMethod` enum +- `Interval` class now supports degenerate intervals containing only one element ### Removed - Support for Python 3.9 removed due to new [BoTorch requirements](https://github.com/pytorch/botorch/pull/2293) @@ -36,7 +39,10 @@ _ `_optional` subpackage for managing optional dependencies ### Deprecations - `SequentialGreedyRecommender` class replaced with `BotorchRecommender` - +- `SubspaceContinuous.samples_random` has been replaced with + `SubspaceContinuous.sample_uniform` +- `SubspaceContinuous.samples_full_factorial` has been replaced with + `SubspaceContinuous.sample_from_full_factorial` ## [0.9.1] - 2024-06-04 ### Changed diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 08f2b6bb2..9f9e06f12 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -22,3 +22,5 @@ Documentation and general feedback - Rim Rihana (Merck KGaA, Darmstadt, Germany): Human readable output for search spaces +- Di Jin (Merck Life Science KGaA, Darmstadt, Germany) + Cardinality constraints diff --git a/baybe/constraints/__init__.py b/baybe/constraints/__init__.py index 01293a7b0..b7acac491 100644 --- a/baybe/constraints/__init__.py +++ b/baybe/constraints/__init__.py @@ -2,6 +2,7 @@ from baybe.constraints.conditions import SubSelectionCondition, ThresholdCondition from baybe.constraints.continuous import ( + ContinuousCardinalityConstraint, ContinuousLinearEqualityConstraint, ContinuousLinearInequalityConstraint, ) @@ -23,6 +24,7 @@ "SubSelectionCondition", "ThresholdCondition", # --- Continuous constraints ---# + "ContinuousCardinalityConstraint", "ContinuousLinearEqualityConstraint", "ContinuousLinearInequalityConstraint", # --- Discrete constraints ---# diff --git a/baybe/constraints/base.py b/baybe/constraints/base.py index 444e9c0be..005e25ed8 100644 --- a/baybe/constraints/base.py +++ b/baybe/constraints/base.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Sequence +from collections.abc import Collection, Sequence from typing import TYPE_CHECKING, Any, ClassVar import numpy as np @@ -107,11 +107,7 @@ def get_invalid(self, data: pd.DataFrame) -> pd.Index: @define class ContinuousConstraint(Constraint, ABC): - """Abstract base class for continuous constraints. - - Continuous constraints use parameter lists and coefficients to define in-/equality - constraints over a continuous parameter space. - """ + """Abstract base class for continuous constraints.""" # class variables eval_during_creation: ClassVar[bool] = False @@ -120,6 +116,15 @@ class ContinuousConstraint(Constraint, ABC): eval_during_modeling: ClassVar[bool] = True # See base class. + +@define +class ContinuousLinearConstraint(ContinuousConstraint, ABC): + """Abstract base class for continuous linear constraints. + + Continuous linear constraints use parameter lists and coefficients to define + in-/equality constraints over a continuous parameter space. + """ + # object variables coefficients: list[float] = field() """In-/equality coefficient for each entry in ``parameters``.""" @@ -148,6 +153,25 @@ def _default_coefficients(self): """Return equal weight coefficients as default.""" return [1.0] * len(self.parameters) + def _drop_parameters( + self, parameter_names: Collection[str] + ) -> ContinuousLinearConstraint: + """Create a copy of the constraint with certain parameters removed. + + Args: + parameter_names: The names of the parameter to be removed. + + Returns: + The reduced constraint. + """ + parameters = [p for p in self.parameters if p not in parameter_names] + coefficients = [ + c + for p, c in zip(self.parameters, self.coefficients) + if p not in parameter_names + ] + return ContinuousLinearConstraint(parameters, coefficients, self.rhs) + def to_botorch( self, parameters: Sequence[NumericalContinuousParameter], idx_offset: int = 0 ) -> tuple[Tensor, Tensor, float]: @@ -181,6 +205,10 @@ def to_botorch( ) +class ContinuousNonlinearConstraint(ContinuousConstraint, ABC): + """Abstract base class for nonlinear constraints.""" + + # Register (un-)structure hooks converter.register_unstructure_hook(Constraint, unstructure_base) converter.register_structure_hook(Constraint, get_base_structure_hook(Constraint)) diff --git a/baybe/constraints/continuous.py b/baybe/constraints/continuous.py index ccd679c97..15a0a4b05 100644 --- a/baybe/constraints/continuous.py +++ b/baybe/constraints/continuous.py @@ -1,12 +1,19 @@ """Continuous constraints.""" -from attr import define +import math -from baybe.constraints.base import ContinuousConstraint +import numpy as np +from attrs import define, field +from attrs.validators import ge, instance_of + +from baybe.constraints.base import ( + ContinuousLinearConstraint, + ContinuousNonlinearConstraint, +) @define -class ContinuousLinearEqualityConstraint(ContinuousConstraint): +class ContinuousLinearEqualityConstraint(ContinuousLinearConstraint): """Class for continuous equality constraints. The constraint is defined as ``sum_i[ x_i * c_i ] == rhs``, where x_i are the @@ -19,7 +26,7 @@ class ContinuousLinearEqualityConstraint(ContinuousConstraint): @define -class ContinuousLinearInequalityConstraint(ContinuousConstraint): +class ContinuousLinearInequalityConstraint(ContinuousLinearConstraint): """Class for continuous inequality constraints. The constraint is defined as ``sum_i[ x_i * c_i ] >= rhs``, where x_i are the @@ -31,3 +38,91 @@ class ContinuousLinearInequalityConstraint(ContinuousConstraint): The class has no real content as it only serves the purpose of distinguishing the constraints. """ + + +@define +class ContinuousCardinalityConstraint(ContinuousNonlinearConstraint): + """Class for continuous cardinality constraints. + + Places a constraint on the set of nonzero (i.e. "active") values among the + specified parameters, bounding it between the two given integers, + ``min_cardinality`` <= |{p_i : p_i != 0}| <= ``max_cardinality`` + where ``{p_i}`` are the parameters specified for the constraint. + + Note that this can be equivalently regarded as L0-constraint on the vector + containing the specified parameters. + """ + + min_cardinality: int = field(default=0, validator=[instance_of(int), ge(0)]) + "The minimum required cardinality." + + max_cardinality: int = field(validator=instance_of(int)) + "The maximum allowed cardinality." + + @max_cardinality.default + def _default_max_cardinality(self): + """Use the number of involved parameters as the upper limit by default.""" + return len(self.parameters) + + def __attrs_post_init__(self): + """Validate the cardinality bounds. + + Raises: + ValueError: If the provided cardinality bounds are invalid. + ValueError: If the provided cardinality bounds impose no constraint. + """ + if self.min_cardinality > self.max_cardinality: + raise ValueError( + f"The lower cardinality bound cannot be larger than the upper bound. " + f"Provided values: {self.max_cardinality=}, {self.min_cardinality=}." + ) + + if self.max_cardinality > len(self.parameters): + raise ValueError( + f"The cardinality bound cannot exceed the number of parameters. " + f"Provided values: {self.max_cardinality=}, {len(self.parameters)=}." + ) + + if self.min_cardinality == 0 and self.max_cardinality == len(self.parameters): + raise ValueError( + f"No constraint of type `{self.__class__.__name__}' is required " + f"when 0 <= cardinality <= len(parameters)." + ) + + def sample_inactive_parameters(self, batch_size: int = 1) -> list[set[str]]: + """Sample sets of inactive parameters according to the cardinality constraints. + + Args: + batch_size: The number of parameter sets to be sampled. + + Returns: + A list of sampled inactive parameter sets, where each set holds the + corresponding parameter names. + """ + # The number of possible parameter configuration per set cardinality + n_configurations_per_cardinality = [ + math.comb(len(self.parameters), n) + for n in range(self.min_cardinality, self.max_cardinality + 1) + ] + + # Probability of each set cardinality under the assumption that all possible + # inactive parameter sets are equally likely + probabilities = n_configurations_per_cardinality / np.sum( + n_configurations_per_cardinality + ) + + # Sample the number of active/inactive parameters + n_active_params = np.random.choice( + np.arange(self.min_cardinality, self.max_cardinality + 1), + batch_size, + p=probabilities, + ) + n_inactive_params = len(self.parameters) - n_active_params + + # Sample the inactive parameters + inactive_params = [ + set(np.random.choice(self.parameters, n_inactive, replace=False)) + for n_inactive in n_inactive_params + ] + + return inactive_params diff --git a/baybe/constraints/validation.py b/baybe/constraints/validation.py index de85f3273..0d2fdf1fd 100644 --- a/baybe/constraints/validation.py +++ b/baybe/constraints/validation.py @@ -1,8 +1,10 @@ """Validation functionality for constraints.""" from collections.abc import Collection +from itertools import combinations from baybe.constraints.base import Constraint +from baybe.constraints.continuous import ContinuousCardinalityConstraint from baybe.constraints.discrete import DiscreteDependenciesConstraint from baybe.parameters.base import Parameter @@ -15,6 +17,8 @@ def validate_constraints( # noqa: DOC101, DOC103 Raises: ValueError: If there is more than one :class:`baybe.constraints.discrete.DiscreteDependenciesConstraint` declared. + ValueError: If any two continuous cardinality constraints have an overlapping + parameter set. ValueError: If any constraint contains an invalid parameter name. ValueError: If any continuous constraint includes a discrete parameter. ValueError: If any discrete constraint includes a continuous parameter. @@ -25,6 +29,10 @@ def validate_constraints( # noqa: DOC101, DOC103 f"Please specify all dependencies in one single constraint." ) + validate_cardinality_constraints_are_nonoverlapping( + [con for con in constraints if isinstance(con, ContinuousCardinalityConstraint)] + ) + param_names_all = [p.name for p in parameters] 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] @@ -53,3 +61,24 @@ def validate_constraints( # noqa: DOC101, DOC103 f"that is continuous. Parameter list of the affected constraint: " f"{constraint.parameters}" ) + + +def validate_cardinality_constraints_are_nonoverlapping( + constraints: Collection[ContinuousCardinalityConstraint] +) -> None: + """Validate that cardinality constraints are non-overlapping. + + Args: + constraints: A collection of continuous cardinality constraints. + + Raises: + ValueError: If any two continuous cardinality constraints have an overlapping + parameter set. + """ + for c1, c2 in combinations(constraints, 2): + if (s1 := set(c1.parameters)).intersection(s2 := set(c2.parameters)): + raise ValueError( + f"Constraints of type `{ContinuousCardinalityConstraint.__name__}` " + f"cannot share the same parameters. Found the following overlapping " + f"parameter sets: {s1}, {s2}." + ) diff --git a/baybe/parameters/numerical.py b/baybe/parameters/numerical.py index 4501b81d4..f85569b12 100644 --- a/baybe/parameters/numerical.py +++ b/baybe/parameters/numerical.py @@ -122,6 +122,10 @@ def _validate_bounds(self, _: Any, value: Interval) -> None: # noqa: DOC101, DO f"of {value.to_tuple()}. Infinite intervals for parameters are " f"currently not supported." ) + if value.is_degenerate: + raise ValueError( + "The interval specified by the parameter bounds cannot be degenerate." + ) def is_in_range(self, item: float) -> bool: # noqa: D102 # See base class. diff --git a/baybe/recommenders/naive.py b/baybe/recommenders/naive.py index 304e5c3b6..c9d10acb5 100644 --- a/baybe/recommenders/naive.py +++ b/baybe/recommenders/naive.py @@ -116,7 +116,7 @@ def recommend( # noqa: D102 # To make things simple, we sample a single point in the continuous space which # will then be attached to every discrete point when the acquisition function # is evaluated. - cont_part = searchspace.continuous.samples_random(1) + cont_part = searchspace.continuous.sample_uniform(1) cont_part_tensor = to_tensor(cont_part).unsqueeze(-2) # Get discrete candidates. The metadata flags are ignored since the search space diff --git a/baybe/recommenders/pure/nonpredictive/sampling.py b/baybe/recommenders/pure/nonpredictive/sampling.py index 846bf0b1a..7dcfa5733 100644 --- a/baybe/recommenders/pure/nonpredictive/sampling.py +++ b/baybe/recommenders/pure/nonpredictive/sampling.py @@ -29,7 +29,7 @@ def _recommend_hybrid( if searchspace.type == SearchSpaceType.DISCRETE: return candidates_comp.sample(batch_size) - cont_random = searchspace.continuous.samples_random(n_points=batch_size) + cont_random = searchspace.continuous.sample_uniform(batch_size=batch_size) if searchspace.type == SearchSpaceType.CONTINUOUS: return cont_random diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index 061e71fb0..86fa9c28f 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -2,7 +2,9 @@ from __future__ import annotations +import warnings from collections.abc import Collection, Sequence +from itertools import chain from typing import TYPE_CHECKING, Any, cast import numpy as np @@ -10,9 +12,14 @@ from attr import define, field from baybe.constraints import ( + ContinuousCardinalityConstraint, ContinuousLinearEqualityConstraint, ContinuousLinearInequalityConstraint, ) +from baybe.constraints.base import ContinuousNonlinearConstraint +from baybe.constraints.validation import ( + validate_cardinality_constraints_are_nonoverlapping, +) from baybe.parameters import NumericalContinuousParameter from baybe.parameters.base import ContinuousParameter from baybe.parameters.utils import get_parameters_from_dataframe @@ -25,6 +32,8 @@ if TYPE_CHECKING: from baybe.searchspace.core import SearchSpace +_MAX_CARDINALITY_SAMPLING_ATTEMPTS = 10_000 + @define class SubspaceContinuous(SerialMixin): @@ -38,17 +47,22 @@ class SubspaceContinuous(SerialMixin): parameters: tuple[NumericalContinuousParameter, ...] = field( converter=to_tuple, validator=lambda _, __, x: validate_parameter_names(x) ) - """The list of parameters of the subspace.""" + """The parameters of the subspace.""" constraints_lin_eq: tuple[ContinuousLinearEqualityConstraint, ...] = field( converter=to_tuple, factory=tuple ) - """List of linear equality constraints.""" + """Linear equality constraints.""" constraints_lin_ineq: tuple[ContinuousLinearInequalityConstraint, ...] = field( converter=to_tuple, factory=tuple ) - """List of linear inequality constraints.""" + """Linear inequality constraints.""" + + constraints_nonlin: tuple[ContinuousNonlinearConstraint, ...] = field( + converter=to_tuple, factory=tuple + ) + """Nonlinear constraints.""" def __str__(self) -> str: if self.is_empty: @@ -63,9 +77,13 @@ def __str__(self) -> str: ineq_constraints_list = [ constr.summary() for constr in self.constraints_lin_ineq ] + nonlin_constraints_list = [ + constr.summary() for constr in self.constraints_nonlin + ] param_df = pd.DataFrame(param_list) lin_eq_constr_df = pd.DataFrame(eq_constraints_list) lin_ineq_constr_df = pd.DataFrame(ineq_constraints_list) + cardinality_constr_df = pd.DataFrame(nonlin_constraints_list) # Put all attributes of the continuous class in one string continuous_str = f"""{start_bold}Continuous Search Space{end_bold} @@ -73,10 +91,29 @@ def __str__(self) -> str: \n{start_bold}List of Linear Equality Constraints{end_bold} \r{pretty_print_df(lin_eq_constr_df)} \n{start_bold}List of Linear Inequality Constraints{end_bold} - \r{pretty_print_df(lin_ineq_constr_df)}""" + \r{pretty_print_df(lin_ineq_constr_df)} + \n{start_bold}List of Cardinality Constraints{end_bold} + \r{pretty_print_df(cardinality_constr_df)}""" return continuous_str.replace("\n", "\n ").replace("\r", "\r ") + @property + def constraints_cardinality(self) -> tuple[ContinuousCardinalityConstraint, ...]: + """Cardinality constraints.""" + return tuple( + c + for c in self.constraints_nonlin + if isinstance(c, ContinuousCardinalityConstraint) + ) + + @constraints_nonlin.validator + 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 + ) + def to_searchspace(self) -> SearchSpace: """Turn the subspace into a search space with no discrete part.""" from baybe.searchspace.core import SearchSpace @@ -177,6 +214,25 @@ def param_bounds_comp(self) -> np.ndarray: return np.empty((2, 0), dtype=DTypeFloatNumpy) return np.stack([p.bounds.to_ndarray() for p in self.parameters]).T + def _drop_parameters(self, parameter_names: Collection[str]) -> SubspaceContinuous: + """Create a copy of the subspace with certain parameters removed. + + Args: + parameter_names: The names of the parameter to be removed. + + Returns: + The reduced subspace. + """ + return SubspaceContinuous( + parameters=[p for p in self.parameters if p.name not in parameter_names], + constraints_lin_eq=[ + c._drop_parameters(parameter_names) for c in self.constraints_lin_eq + ], + constraints_lin_ineq=[ + c._drop_parameters(parameter_names) for c in self.constraints_lin_ineq + ], + ) + def transform( self, data: pd.DataFrame, @@ -195,28 +251,70 @@ def transform( return comp_rep def samples_random(self, n_points: int = 1) -> pd.DataFrame: - """Get random point samples from the continuous space. + """Deprecated!""" # noqa: D401 + warnings.warn( + f"The method '{SubspaceContinuous.samples_random.__name__}' " + f"has been deprecated and will be removed in a future version. " + f"Use '{SubspaceContinuous.sample_uniform.__name__}' instead.", + DeprecationWarning, + ) + return self.sample_uniform(n_points) + + def sample_uniform(self, batch_size: int = 1) -> pd.DataFrame: + """Draw uniform random parameter configurations from the continuous space. Args: - n_points: Number of points that should be sampled. + batch_size: The number of parameter configurations to be sampled. Returns: - A dataframe containing the points as rows with columns corresponding to the - parameter names. + A dataframe containing the parameter configurations as rows with columns + corresponding to the parameter names. + + Raises: + ValueError: If the subspace contains unsupported nonlinear constraints. """ + if not all( + isinstance(c, ContinuousCardinalityConstraint) + for c in self.constraints_nonlin + ): + raise ValueError( + f"Currently, only nonlinear constraints of type " + f"'{ContinuousCardinalityConstraint.__name__}' are supported." + ) + if not self.parameters: - return pd.DataFrame() + return pd.DataFrame(index=pd.RangeIndex(0, batch_size)) + + if ( + len(self.constraints_lin_eq) == 0 + and len(self.constraints_lin_ineq) == 0 + and len(self.constraints_cardinality) == 0 + ): + return self._sample_from_bounds(batch_size, self.param_bounds_comp) + + if len(self.constraints_cardinality) == 0: + return self._sample_from_polytope(batch_size, self.param_bounds_comp) + + return self._sample_from_polytope_with_cardinality_constraints(batch_size) + + def _sample_from_bounds(self, batch_size: int, bounds: np.ndarray) -> pd.DataFrame: + """Draw uniform random samples over a hyperrectangle-shaped space.""" + points = np.random.uniform( + low=bounds[0, :], high=bounds[1, :], size=(batch_size, len(self.parameters)) + ) + + return pd.DataFrame(points, columns=self.param_names) + + def _sample_from_polytope( + self, batch_size: int, bounds: np.ndarray + ) -> pd.DataFrame: + """Draw uniform random samples from a polytope.""" import torch from botorch.utils.sampling import get_polytope_samples - # TODO Revisit: torch and botorch here are actually only necessary if there - # are constraints. If there are none and the lists are empty we can just sample - # without the get_polytope_samples, which means torch and botorch - # wouldn't be needed. - points = get_polytope_samples( - n=n_points, - bounds=torch.from_numpy(self.param_bounds_comp), + n=batch_size, + bounds=torch.from_numpy(bounds), equality_constraints=[ c.to_botorch(self.parameters) for c in self.constraints_lin_eq ], @@ -224,32 +322,98 @@ def samples_random(self, n_points: int = 1) -> pd.DataFrame: c.to_botorch(self.parameters) for c in self.constraints_lin_ineq ], ) - return pd.DataFrame(points, columns=self.param_names) + 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: + raise RuntimeError( + f"This method should not be called without any constraints of type " + f"'{ContinuousCardinalityConstraint.__name__}' in place. " + f"Use '{SubspaceContinuous._sample_from_bounds.__name__}' " + f"or '{SubspaceContinuous._sample_from_polytope.__name__}' instead." + ) + + # List to store the created samples + samples: list[pd.DataFrame] = [] + + # Counter for failed sampling attempts + n_fails = 0 + + while len(samples) < batch_size: + # Randomly set some parameters inactive + inactive_params_sample = self._sample_inactive_parameters(1)[0] + + # Remove the inactive parameters from the search space + subspace_without_cardinality_constraint = self._drop_parameters( + inactive_params_sample + ) + + # Sample from the reduced space + try: + sample = subspace_without_cardinality_constraint.sample_uniform(1) + samples.append(sample) + except ValueError: + n_fails += 1 + + # Avoid infinite loop + if n_fails >= _MAX_CARDINALITY_SAMPLING_ATTEMPTS: + raise RuntimeError( + f"The number of failed sampling attempts has exceeded the limit " + f"of {_MAX_CARDINALITY_SAMPLING_ATTEMPTS}. " + f"It appears that the feasible region of the search space is very " + f"small. Please review the search space constraints." + ) + + # Combine the samples and fill in inactive parameters + parameter_names = [p.name for p in self.parameters] + return ( + pd.concat(samples, ignore_index=True) + .reindex(columns=parameter_names) + .fillna(0.0) + ) + + def _sample_inactive_parameters(self, batch_size: int = 1) -> list[set[str]]: + """Sample inactive parameters according to the given cardinality constraints.""" + inactives_per_constraint = [ + con.sample_inactive_parameters(batch_size) + for con in self.constraints_cardinality + ] + return [set(chain(*x)) for x in zip(*inactives_per_constraint)] + def samples_full_factorial(self, n_points: int = 1) -> pd.DataFrame: - """Get random point samples from the full factorial of the continuous space. + """Deprecated!""" # noqa: D401 + warnings.warn( + f"The method '{SubspaceContinuous.samples_full_factorial.__name__}' " + f"has been deprecated and will be removed in a future version. " + f"Use '{SubspaceContinuous.sample_from_full_factorial.__name__}' instead.", + DeprecationWarning, + ) + return self.sample_from_full_factorial(n_points) + + def sample_from_full_factorial(self, batch_size: int = 1) -> pd.DataFrame: + """Draw parameter configurations from the full factorial of the space. Args: - n_points: Number of points that should be sampled. + batch_size: The number of parameter configurations to be sampled. Returns: - A dataframe containing the points as rows with columns corresponding to the - parameter names. + A dataframe containing the parameter configurations as rows with columns + corresponding to the parameter names. Raises: ValueError: If there are not enough points to sample from. """ - full_factorial = self.full_factorial - - if len(full_factorial) < n_points: + if len(full_factorial := self.full_factorial) < batch_size: raise ValueError( - f"You are trying to sample {n_points} points from the full factorial of" - f" the continuous space bounds, but it has only {len(full_factorial)} " - f"points." + f"You are trying to sample {batch_size} points from the full factorial " + f"of the continuous space bounds, but it has only " + f"{len(full_factorial)} points." ) - return full_factorial.sample(n=n_points).reset_index(drop=True) + return full_factorial.sample(n=batch_size).reset_index(drop=True) @property def full_factorial(self) -> pd.DataFrame: diff --git a/baybe/searchspace/core.py b/baybe/searchspace/core.py index 6ad4947d7..b762042bd 100644 --- a/baybe/searchspace/core.py +++ b/baybe/searchspace/core.py @@ -15,11 +15,8 @@ ContinuousLinearInequalityConstraint, validate_constraints, ) -from baybe.constraints.base import Constraint -from baybe.parameters import ( - SubstanceEncoding, - TaskParameter, -) +from baybe.constraints.base import Constraint, ContinuousNonlinearConstraint +from baybe.parameters import SubstanceEncoding, TaskParameter from baybe.parameters.base import Parameter from baybe.searchspace.continuous import SubspaceContinuous from baybe.searchspace.discrete import ( @@ -153,6 +150,9 @@ def from_product( for c in constraints if isinstance(c, ContinuousLinearInequalityConstraint) ], + constraints_nonlin=[ + c for c in constraints if isinstance(c, ContinuousNonlinearConstraint) + ], ) return SearchSpace(discrete=discrete, continuous=continuous) @@ -214,6 +214,7 @@ def constraints(self) -> tuple[Constraint, ...]: *self.discrete.constraints, *self.continuous.constraints_lin_eq, *self.continuous.constraints_lin_ineq, + *self.continuous.constraints_nonlin, ) @property diff --git a/baybe/targets/numerical.py b/baybe/targets/numerical.py index a1cf08133..267643ffb 100644 --- a/baybe/targets/numerical.py +++ b/baybe/targets/numerical.py @@ -97,6 +97,10 @@ def _validate_bounds(self, _: Any, bounds: Interval) -> None: # noqa: DOC101, D # for the desirability approach if bounds.is_half_bounded: raise ValueError("Targets on half-bounded intervals are not supported.") + if bounds.is_degenerate: + raise ValueError( + "The interval specified by the target bounds cannot be degenerate." + ) if self.mode is TargetMode.MATCH and not bounds.is_bounded: raise ValueError( f"Target '{self.name}' is in {TargetMode.MATCH.name} mode," diff --git a/baybe/utils/interval.py b/baybe/utils/interval.py index 4ebb27a66..0a4ea8d9e 100644 --- a/baybe/utils/interval.py +++ b/baybe/utils/interval.py @@ -39,12 +39,17 @@ def _validate_order(self, _: Any, upper: float) -> None: # noqa: DOC101, DOC103 Raises: ValueError: If the upper end is not larger than the lower end. """ - if upper <= self.lower: + if upper < self.lower: raise ValueError( - f"The upper interval bound (provided value: {upper}) must be larger " + f"The upper interval bound (provided value: {upper}) cannot be smaller " f"than the lower bound (provided value: {self.lower})." ) + @property + def is_degenerate(self) -> bool: + """Check if the interval is degenerate (i.e., contains only a single number).""" + return self.lower == self.upper + @property def is_bounded(self) -> bool: """Check if the interval is bounded.""" diff --git a/tests/test_cardinality_constraint.py b/tests/test_cardinality_constraint.py new file mode 100644 index 000000000..223b975ab --- /dev/null +++ b/tests/test_cardinality_constraint.py @@ -0,0 +1,131 @@ +"""Tests for the continuous cardinality constraint.""" + +from itertools import combinations_with_replacement + +import numpy as np +import pytest + +from baybe.constraints.continuous import ( + ContinuousCardinalityConstraint, + ContinuousLinearEqualityConstraint, + ContinuousLinearInequalityConstraint, +) +from baybe.parameters.numerical import NumericalContinuousParameter +from baybe.recommenders.pure.nonpredictive.sampling import RandomRecommender +from baybe.searchspace.core import SearchSpace + + +def _get_searchspace( + n_parameters: int, min_cardinality: int, max_cardinality: int +) -> SearchSpace: + """Create a unit-cube searchspace with cardinality constraint on all parameters.""" + parameters = [ + NumericalContinuousParameter(name=f"x_{i}", bounds=(0, 1)) + for i in range(n_parameters) + ] + constraints = [ + ContinuousCardinalityConstraint( + parameters=[f"x_{i}" for i in range(n_parameters)], + min_cardinality=min_cardinality, + max_cardinality=max_cardinality, + ) + ] + searchspace = SearchSpace.from_product(parameters, constraints) + return searchspace + + +def test_sampling(): + """ + Polytope sampling with cardinality constraints respects all involved constraints + and produces distinct samples. + """ # noqa + N_PARAMETERS = 6 + MAX_NONZERO = 4 + MIN_NONZERO = 2 + N_POINTS = 20 + TOLERANCE = 1e-3 + + parameters = [ + NumericalContinuousParameter(name=f"x_{i+1}", bounds=(0, 1)) + for i in range(N_PARAMETERS) + ] + params_equality = ["x_1", "x_2", "x_3", "x_4"] + coeffs_equality = [0.9, 0.6, 2.8, 6.1] + rhs_equality = 4.2 + params_inequality = ["x_1", "x_2", "x_5", "x_6"] + coeffs_inequality = [4.7, 1.4, 4.6, 8.6] + rhs_inequality = 1.3 + params_cardinality = ["x_1", "x_2", "x_3", "x_5"] + constraints = [ + ContinuousLinearEqualityConstraint( + parameters=params_equality, coefficients=coeffs_equality, rhs=rhs_equality + ), + ContinuousLinearInequalityConstraint( + parameters=params_inequality, + coefficients=coeffs_inequality, + rhs=rhs_equality, + ), + ContinuousCardinalityConstraint( + parameters=params_cardinality, + max_cardinality=MAX_NONZERO, + min_cardinality=MIN_NONZERO, + ), + ] + searchspace = SearchSpace.from_product(parameters, constraints) + + samples = searchspace.continuous.sample_uniform(N_POINTS) + + # Assert that cardinality constraint is fulfilled + n_nonzero = np.sum(~np.isclose(samples[params_cardinality], 0.0), axis=1) + assert np.all(n_nonzero >= MIN_NONZERO) and np.all(n_nonzero <= MAX_NONZERO) + + # Assert that linear equality constraint is fulfilled + assert np.allclose( + np.sum(samples[params_equality] * coeffs_equality, axis=1), + rhs_equality, + atol=TOLERANCE, + ) + + # Assert that linear non-equality constraint is fulfilled + assert ( + np.sum(samples[params_inequality] * coeffs_inequality, axis=1) + .ge(rhs_inequality - TOLERANCE) + .all() + ) + + # Assert that we obtain as many (unique!) samples as requested + assert len(samples.drop_duplicates()) == N_POINTS + + +# Combinations of cardinalities to be tested +_cardinalities = sorted(combinations_with_replacement(range(0, 10), 2)) + + +@pytest.mark.parametrize( + "cardinalities", _cardinalities, ids=[str(x) for x in _cardinalities] +) +def test_random_recommender_with_cardinality_constraint(cardinalities): + """ + Recommendations generated by a `RandomRecommender` under a cardinality constraint + have the expected number of nonzero elements. + """ # noqa + N_PARAMETERS = 10 + BATCH_SIZE = 10 + min_cardinality, max_cardinality = cardinalities + + searchspace = _get_searchspace(N_PARAMETERS, min_cardinality, max_cardinality) + recommender = RandomRecommender() + recommendations = recommender.recommend( + searchspace=searchspace, + batch_size=BATCH_SIZE, + ) + + # Assert that cardinality constraint is fulfilled + n_nonzero = np.sum(~np.isclose(recommendations, 0.0), 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(recommendations) == BATCH_SIZE + + # If there are duplicates, they must all come from the case cardinality = 0 + assert np.all(recommendations[recommendations.duplicated()] == 0.0) diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index f3ae2f593..22e02cdd6 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -11,6 +11,7 @@ from baybe.objective import Objective as OldObjective from baybe.objectives.base import Objective from baybe.objectives.desirability import DesirabilityObjective +from baybe.parameters.numerical import NumericalContinuousParameter from baybe.recommenders.base import RecommenderProtocol from baybe.recommenders.meta.sequential import TwoPhaseMetaRecommender from baybe.recommenders.pure.bayesian import ( @@ -21,6 +22,7 @@ FPSRecommender, RandomRecommender, ) +from baybe.searchspace.continuous import SubspaceContinuous from baybe.searchspace.core import SearchSpace from baybe.strategies import ( SequentialStrategy, @@ -207,3 +209,17 @@ def test_deprecated_sequentialgreedyrecommender_class(): """Using the deprecated `SequentialGreedyRecommender` class raises a warning.""" with pytest.warns(DeprecationWarning): SequentialGreedyRecommender() + + +def test_deprecated_samples_random(): + """Using the deprecated `samples_random` method raises a warning.""" + with pytest.warns(DeprecationWarning): + parameters = [NumericalContinuousParameter("x", (0, 1))] + SubspaceContinuous(parameters).samples_random(n_points=1) + + +def test_deprecated_samples_full_factorial(): + """Using the deprecated `samples_full_factorial` method raises a warning.""" + with pytest.warns(DeprecationWarning): + parameters = [NumericalContinuousParameter("x", (0, 1))] + SubspaceContinuous(parameters).samples_full_factorial(n_points=1) diff --git a/tests/test_searchspace.py b/tests/test_searchspace.py index 00f8f81b6..44eac9c50 100644 --- a/tests/test_searchspace.py +++ b/tests/test_searchspace.py @@ -5,6 +5,7 @@ import pytest from baybe.constraints import ( + ContinuousCardinalityConstraint, ContinuousLinearEqualityConstraint, ContinuousLinearInequalityConstraint, DiscreteSumConstraint, @@ -223,3 +224,26 @@ def test_searchspace_memory_estimate(searchspace: SearchSpace): estimate_comp, actual_comp, ) + + +def test_cardinality_constraints_with_overlapping_parameters(): + """Creating cardinality constraints with overlapping parameters raises an error.""" + parameters = [ + NumericalContinuousParameter("c1", (0, 1)), + NumericalContinuousParameter("c2", (0, 1)), + NumericalContinuousParameter("c3", (0, 1)), + ] + with pytest.raises(ValueError, match="cannot share the same parameters"): + SubspaceContinuous( + parameters=parameters, + constraints_nonlin=[ + ContinuousCardinalityConstraint( + parameters=["c1", "c2"], + max_cardinality=1, + ), + ContinuousCardinalityConstraint( + parameters=["c2", "c3"], + max_cardinality=1, + ), + ], + ) diff --git a/tests/validation/test_constraint_validation.py b/tests/validation/test_constraint_validation.py new file mode 100644 index 000000000..2bee6bdd8 --- /dev/null +++ b/tests/validation/test_constraint_validation.py @@ -0,0 +1,23 @@ +"""Validation tests for constraints.""" + +import pytest +from pytest import param + +from baybe.constraints.continuous import ContinuousCardinalityConstraint + + +@pytest.mark.parametrize( + ("cardinalities", "error", "match"), + [ + param(("0", 0), TypeError, "must be ", id="type_min"), + param((0, "1"), TypeError, "must be ", id="type_max"), + param((-1, 0), ValueError, "'min_cardinality' must be >= 0", id="loo_small"), + param((1, 0), ValueError, "larger than the upper bound", id="wrong_order"), + param((0, 3), ValueError, "exceed the number of parameters", id="too_large"), + param((0, 2), ValueError, r"No constraint .* required", id="inactive"), + ], +) +def test_invalid_cardinalities(cardinalities, error, match): + """Providing an invalid parameter name raises an exception.""" + with pytest.raises(error, match=match): + ContinuousCardinalityConstraint(["x", "y"], *cardinalities) diff --git a/tests/validation/test_interval_validation.py b/tests/validation/test_interval_validation.py index ae5ea3ba6..7ac8da2ce 100644 --- a/tests/validation/test_interval_validation.py +++ b/tests/validation/test_interval_validation.py @@ -9,8 +9,7 @@ @pytest.mark.parametrize( "bounds", [ - param((0.0, 0.0), id="single_element"), - param((1.0, 0.0), id="unsorted_bounds"), + param((1.0, 0.0), id="wrong_bounds_order"), ], ) def test_invalid_range(bounds): diff --git a/tests/validation/test_parameter_validation.py b/tests/validation/test_parameter_validation.py index b8c88b4b6..ad154e9cd 100644 --- a/tests/validation/test_parameter_validation.py +++ b/tests/validation/test_parameter_validation.py @@ -75,16 +75,17 @@ def test_invalid_values_numerical_discrete_parameter(values, error): @pytest.mark.parametrize( - "bounds", + ("bounds", "error"), [ - param([-np.inf, np.inf], id="infinite"), - param([0, np.inf], id="open_right"), - param([-np.inf, 0], id="open_left"), + param([-np.inf, np.inf], InfiniteIntervalError, id="infinite"), + param([0, np.inf], InfiniteIntervalError, id="open_right"), + param([-np.inf, 0], InfiniteIntervalError, id="open_left"), + param([0, 0], ValueError, id="degenerate"), ], ) -def test_invalid_bounds_numerical_continuous_parameter(bounds): +def test_invalid_bounds_numerical_continuous_parameter(bounds, error): """Creating an unbounded parameter raises an exception.""" - with pytest.raises(InfiniteIntervalError): + with pytest.raises(error): NumericalContinuousParameter(name="invalid_values", bounds=bounds) diff --git a/tests/validation/test_target_validation.py b/tests/validation/test_target_validation.py index 8b7ff7955..22a54afa8 100644 --- a/tests/validation/test_target_validation.py +++ b/tests/validation/test_target_validation.py @@ -11,6 +11,7 @@ [ param("MATCH", None, id="non_closed_match_mode"), param("MAX", (0, None), id="half_open"), + param("MAX", (0, 0), id="degenerate"), ], ) def test_invalid_bounds_mode(mode, bounds):