diff --git a/CHANGELOG.md b/CHANGELOG.md index d9931d0d0e..2859922509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 for determining the Pareto front - Interpoint constraints for continuous search spaces +### Changed +- `SubspaceContinuous` now offers a simpler interface for passing constraints, + no longer requiring users to manually group constraints according to their type + ### Breaking Changes - `ContinuousLinearConstraint.to_botorch` now returns a collection of constraint tuples instead of a single tuple (needed for interpoint constraints) @@ -27,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 can now be conveniently controlled via the new `Settings` mechanism ### Deprecations +- `SubspaceDiscrete` ignores any `empty_encoding` when provided +- `SubspaceDiscrete` no longer accepts a `comp_rep` argument - `set_random_seed` and `temporary_seed` utility functions - The environment variables `BAYBE_NUMPY_USE_SINGLE_PRECISION`/`BAYBE_TORCH_USE_SINGLE_PRECISION` have been diff --git a/baybe/searchspace/_filtered.py b/baybe/searchspace/_filtered.py index 322837c0c5..2663c53d4d 100644 --- a/baybe/searchspace/_filtered.py +++ b/baybe/searchspace/_filtered.py @@ -33,10 +33,11 @@ def from_subspace( cls, subspace: SubspaceDiscrete, mask_keep: npt.NDArray[np.bool_] ) -> Self: """Filter an existing subspace.""" - return cls( - **asdict(subspace, filter=lambda attr, _: attr.init, recurse=False), - mask_keep=mask_keep, - ) + kwargs = asdict(subspace, filter=lambda attr, _: attr.init, recurse=False) + # Remove deprecated fields (to be dropped with the deprecation) + del kwargs["_empty_encoding"] + del kwargs["_comp_rep"] + return cls(**kwargs, mask_keep=mask_keep) @override def get_candidates(self) -> tuple[pd.DataFrame, pd.DataFrame]: diff --git a/baybe/searchspace/continuous.py b/baybe/searchspace/continuous.py index a3e0fa34f6..947bdc0e8d 100644 --- a/baybe/searchspace/continuous.py +++ b/baybe/searchspace/continuous.py @@ -4,13 +4,15 @@ import gc import math +import warnings from collections.abc import Collection, Iterator, Sequence from itertools import chain, product from typing import TYPE_CHECKING, Any, cast +import cattrs.gen import numpy as np import pandas as pd -from attrs import define, evolve, field, fields +from attrs import define, evolve, field from typing_extensions import override from baybe.constraints import ( @@ -22,6 +24,7 @@ validate_cardinality_constraint_parameter_bounds, validate_cardinality_constraints_are_nonoverlapping, ) +from baybe.exceptions import IncompatibilityError from baybe.parameters import NumericalContinuousParameter from baybe.parameters.base import ContinuousParameter from baybe.parameters.numerical import _FixedNumericalContinuousParameter @@ -51,31 +54,55 @@ class SubspaceContinuous(SerialMixin): """Class for managing continuous subspaces. - Builds the subspace from parameter definitions, keeps - track of search metadata, and provides access to candidate sets and different - parameter views. + Builds the subspace from parameter definitions and optional constraints, + and provides access to candidate sets and different parameter views. """ parameters: tuple[NumericalContinuousParameter, ...] = field( + default=(), converter=sort_parameters, validator=lambda _, __, x: validate_parameter_names(x), ) - """The parameters of the subspace.""" + """The parameters spanning the subspace.""" - constraints_lin_eq: tuple[ContinuousLinearConstraint, ...] = field( - converter=to_tuple, factory=tuple + constraints: tuple[ContinuousConstraint, ...] = field( + default=(), converter=to_tuple ) - """Linear equality constraints.""" + """Optional constraints filtering the subspace.""" - constraints_lin_ineq: tuple[ContinuousLinearConstraint, ...] = field( - converter=to_tuple, factory=tuple - ) - """Linear inequality constraints.""" + def __init__( + self, + parameters: Sequence[NumericalContinuousParameter] | None = None, + constraints: Sequence[ContinuousConstraint] | None = None, + constraints_lin_eq: Sequence[ContinuousLinearConstraint] | None = None, + constraints_lin_ineq: Sequence[ContinuousLinearConstraint] | None = None, + constraints_nonlin: Sequence[ContinuousNonlinearConstraint] | None = None, + ): + parameters = list(parameters) if parameters is not None else [] + constraints = list(constraints) if constraints is not None else [] + + n_constraints = len(constraints) + if constraints_lin_eq is not None: + constraints.extend(constraints_lin_eq) + if constraints_lin_ineq is not None: + constraints.extend(constraints_lin_ineq) + if constraints_nonlin is not None: + constraints.extend(constraints_nonlin) + + if len(constraints) != n_constraints: + warnings.warn( + "You are using the deprecated `constraints_lin_eq`, " + "`constraints_lin_ineq` and/or `constraints_nonlin` arguments to " + "specify constraints. For backward compatibility, we have " + "automatically merged their content into the `constraints` attribute. " + "However, please update your code to directly use the `constraints` " + "argument instead since the deprecated arguments will be removed in " + "a future version.", + DeprecationWarning, + stacklevel=2, + ) - constraints_nonlin: tuple[ContinuousNonlinearConstraint, ...] = field( - converter=to_tuple, factory=tuple - ) - """Nonlinear constraints.""" + self.__attrs_init__(parameters, constraints) @override def __str__(self) -> str: @@ -107,41 +134,40 @@ def __str__(self) -> str: return to_string(self.__class__.__name__, *fields) + @property + def constraints_lin_eq(self) -> tuple[ContinuousLinearConstraint, ...]: + """Linear equality constraints.""" + return tuple( + c + for c in self.constraints + if isinstance(c, ContinuousLinearConstraint) and c.is_eq + ) + + @property + def constraints_lin_ineq(self) -> tuple[ContinuousLinearConstraint, ...]: + """Linear inequality constraints.""" + return tuple( + c + for c in self.constraints + if isinstance(c, ContinuousLinearConstraint) and not c.is_eq + ) + + @property + def constraints_nonlin(self) -> tuple[ContinuousNonlinearConstraint, ...]: + """Nonlinear constraints.""" + return tuple( + c for c in self.constraints if isinstance(c, ContinuousNonlinearConstraint) + ) + @property def constraints_cardinality(self) -> tuple[ContinuousCardinalityConstraint, ...]: """Cardinality constraints.""" return tuple( c - for c in self.constraints_nonlin + for c in self.constraints if isinstance(c, ContinuousCardinalityConstraint) ) - @constraints_lin_eq.validator - def _validate_constraints_lin_eq( - self, _, lst: list[ContinuousLinearConstraint] - ) -> None: - """Validate linear equality constraints.""" - # TODO Remove once eq and ineq constraints are consolidated into one list - if not all(c.is_eq for c in lst): - raise ValueError( - f"The list '{fields(self.__class__).constraints_lin_eq.name}' of " - f"{self.__class__.__name__} only accepts equality constraints, i.e. " - f"the 'operator' for all list items should be '='." - ) - - @constraints_lin_ineq.validator - def _validate_constraints_lin_ineq( - self, _, lst: list[ContinuousLinearConstraint] - ) -> None: - """Validate linear inequality constraints.""" - # TODO Remove once eq and ineq constraints are consolidated into one list - if any(c.is_eq for c in lst): - raise ValueError( - f"The list '{fields(self.__class__).constraints_lin_ineq.name}' of " - f"{self.__class__.__name__} only accepts inequality constraints, i.e. " - f"the 'operator' for all list items should be '>=' or '<='." - ) - @property def n_inactive_parameter_combinations(self) -> int: """The number of possible inactive parameter combinations.""" @@ -159,16 +185,26 @@ def inactive_parameter_combinations(self) -> Iterator[frozenset[str]]: ): yield frozenset(chain(*combination)) - @constraints_nonlin.validator - def _validate_constraints_nonlin(self, _, __) -> None: - """Validate nonlinear constraints.""" - # Note: The passed constraints are accessed indirectly through the property + @constraints.validator + def _validate_constraints(self, _, __) -> None: + """Validate constraints.""" + parameter_names = {p.name for p in self.parameters} + for constraint in self.constraints: + if invalid := set(constraint.parameters) - parameter_names: + raise IncompatibilityError( + f"A constraint of type '{constraint.__class__.__name__}' " + f"references the following parameters that are not part of " + f"the subspace: {invalid}." + ) + validate_cardinality_constraints_are_nonoverlapping( self.constraints_cardinality ) - for con in self.constraints_cardinality: - validate_cardinality_constraint_parameter_bounds(con, self.parameters) + for constraint in self.constraints_cardinality: + validate_cardinality_constraint_parameter_bounds( + constraint, self.parameters + ) def to_searchspace(self) -> SearchSpace: """Turn the subspace into a search space with no discrete part.""" @@ -191,7 +227,7 @@ def from_parameter(cls, parameter: ContinuousParameter) -> SubspaceContinuous: Returns: The created subspace. """ - return cls.from_product([parameter]) + return cls([parameter]) @classmethod def from_product( @@ -199,24 +235,8 @@ def from_product( parameters: Sequence[ContinuousParameter], constraints: Sequence[ContinuousConstraint] | None = None, ) -> SubspaceContinuous: - """See :class:`baybe.searchspace.core.SearchSpace`.""" - constraints = constraints or [] - return SubspaceContinuous( - parameters=[p for p in parameters if p.is_continuous], - constraints_lin_eq=[ - c - for c in constraints - if (isinstance(c, ContinuousLinearConstraint) and c.is_eq) - ], - constraints_lin_ineq=[ - c - for c in constraints - if (isinstance(c, ContinuousLinearConstraint) and not c.is_eq) - ], - constraints_nonlin=[ - c for c in constraints if isinstance(c, ContinuousNonlinearConstraint) - ], - ) + """Alias for `SubspaceContinuous.__init__`.""" + return SubspaceContinuous(parameters, constraints) @classmethod def from_bounds(cls, bounds: pd.DataFrame) -> SubspaceContinuous: @@ -336,28 +356,17 @@ def _drop_parameters(self, parameter_names: Collection[str]) -> SubspaceContinuo """ return SubspaceContinuous( parameters=[p for p in self.parameters if p.name not in parameter_names], - constraints_lin_eq=[ + constraints=[ c._drop_parameters(parameter_names) - for c in self.constraints_lin_eq - if set(c.parameters) - set(parameter_names) - ], - constraints_lin_ineq=[ - c._drop_parameters(parameter_names) - for c in self.constraints_lin_ineq - if set(c.parameters) - set(parameter_names) + for c in self.constraints + if (set(c.parameters) - set(parameter_names)) ], ) @property def is_constrained(self) -> bool: """Boolean indicating if the subspace is constrained in any way.""" - return any( - ( - self.constraints_lin_eq, - self.constraints_lin_ineq, - self.constraints_nonlin, - ) - ) + return bool(self.constraints) @property def has_interpoint_constraints(self) -> bool: @@ -424,9 +433,9 @@ def _enforce_cardinality_constraints( return evolve( self, parameters=adjusted_parameters, - constraints_nonlin=[ + constraints=[ c - for c in self.constraints_nonlin + for c in self.constraints if not isinstance(c, ContinuousCardinalityConstraint) ], ) @@ -670,8 +679,66 @@ def get_parameters_by_name( return tuple(p for p in self.parameters if p.name in names) -# Register deserialization hook -converter.register_structure_hook(SubspaceContinuous, select_constructor_hook) - # Collect leftover original slotted classes processed by `attrs.define` gc.collect() + +# Uncomment when removing the deprecation: +# converter.register_structure_hook(SubspaceContinuous, select_constructor_hook) + +# >>>>> Deprecation +_hook = cattrs.gen.make_dict_structure_fn(SubspaceContinuous, converter) + + +def _structure_hook(specs: dict, cls: type) -> SubspaceContinuous: + """Structure hook that supports both constructor dispatch and legacy fields.""" + if "constructor" in specs: + return select_constructor_hook(specs, cls) + + specs = specs.copy() + specs.pop("type", None) + + # Check if any deprecated constraint fields are present + deprecated_keys = { + "constraints_lin_eq", + "constraints_lin_ineq", + "constraints_nonlin", + } + if deprecated_keys & specs.keys(): + from baybe.constraints.base import ( + ContinuousConstraint, + ContinuousNonlinearConstraint, + ) + + kwargs: dict[str, Any] = {} + if "parameters" in specs: + kwargs["parameters"] = [ + converter.structure(p, NumericalContinuousParameter) + for p in specs["parameters"] + ] + if "constraints" in specs: + kwargs["constraints"] = [ + converter.structure(c, ContinuousConstraint) + for c in specs["constraints"] + ] + if "constraints_lin_eq" in specs: + kwargs["constraints_lin_eq"] = [ + converter.structure(c, ContinuousLinearConstraint) + for c in specs["constraints_lin_eq"] + ] + if "constraints_lin_ineq" in specs: + kwargs["constraints_lin_ineq"] = [ + converter.structure(c, ContinuousLinearConstraint) + for c in specs["constraints_lin_ineq"] + ] + if "constraints_nonlin" in specs: + kwargs["constraints_nonlin"] = [ + converter.structure(c, ContinuousNonlinearConstraint) + for c in specs["constraints_nonlin"] + ] + return SubspaceContinuous(**kwargs) + + return _hook(specs, cls) + + +converter.register_structure_hook(SubspaceContinuous, _structure_hook) +# <<<<< Deprecation diff --git a/baybe/searchspace/core.py b/baybe/searchspace/core.py index 8b0da30c92..1a9c6c0b2d 100644 --- a/baybe/searchspace/core.py +++ b/baybe/searchspace/core.py @@ -100,7 +100,6 @@ def from_product( cls, parameters: Sequence[Parameter], constraints: Sequence[Constraint] | None = None, - empty_encoding: bool = False, ) -> SearchSpace: """Create a search space from a cartesian product. @@ -114,11 +113,6 @@ def from_product( parameters: The parameters spanning the search space. constraints: An optional set of constraints restricting the valid parameter space. - empty_encoding: If ``True``, uses an "empty" encoding for all parameters. - This is useful, for instance, in combination with random search - strategies that do not read the actual parameter values, since it avoids - the (potentially costly) transformation of the parameter values to their - computational representation. Returns: The constructed search space. @@ -136,9 +130,8 @@ def from_product( discrete = SubspaceDiscrete.from_product( parameters=[p for p in parameters if p.is_discrete], # type:ignore[misc] constraints=[c for c in constraints if c.is_discrete], # type:ignore[misc] - empty_encoding=empty_encoding, ) - continuous = SubspaceContinuous.from_product( + continuous = SubspaceContinuous( parameters=[p for p in parameters if p.is_continuous], # type:ignore[misc] constraints=[c for c in constraints if c.is_continuous], # type:ignore[misc] ) @@ -202,9 +195,7 @@ def constraints(self) -> tuple[Constraint, ...]: """Return the constraints of the search space.""" return ( *self.discrete.constraints, - *self.continuous.constraints_lin_eq, - *self.continuous.constraints_lin_ineq, - *self.continuous.constraints_nonlin, + *self.continuous.constraints, ) @property diff --git a/baybe/searchspace/discrete.py b/baybe/searchspace/discrete.py index efae2cfc6b..d88b567008 100644 --- a/baybe/searchspace/discrete.py +++ b/baybe/searchspace/discrete.py @@ -3,14 +3,18 @@ from __future__ import annotations import gc +import warnings from collections.abc import Collection, Sequence +from functools import cached_property from itertools import compress from math import prod -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Annotated, Any +import cattrs import numpy as np import pandas as pd from attrs import define, field +from attrs.validators import deep_iterable, instance_of from cattrs import IterableValidationError from typing_extensions import override @@ -43,6 +47,23 @@ from baybe.searchspace.core import SearchSpace +def _deprecate_argument(error: bool): + """Helper for deprecating legacy arguments.""" # noqa: D401 + + def validator(self, attribute, value): + if value is not None: + msg = ( + f"Providing '{attribute.alias}' to '{self.__class__.__name__}' is no " + f"longer supported. To proceed, simply drop the argument." + ) + if error: + raise DeprecationError(msg) + else: + warnings.warn(msg, DeprecationWarning, stacklevel=3) + + return validator + + @define(kw_only=True) class MemorySize: """Estimated memory size of a :class:`SubspaceDiscrete`.""" @@ -80,33 +101,39 @@ def comp_rep_human_readable(self) -> tuple[float, str]: class SubspaceDiscrete(SerialMixin): """Class for managing discrete subspaces. - Builds the subspace from parameter definitions and optional constraints, keeps - track of search metadata, and provides access to candidate sets and different - parameter views. + Builds the subspace from parameter definitions and optional constraints, + and provides access to candidate sets and different parameter views. """ parameters: tuple[DiscreteParameter, ...] = field( converter=sort_parameters, - validator=lambda _, __, x: validate_parameter_names(x), + validator=[ + deep_iterable(member_validator=instance_of(DiscreteParameter)), + lambda _, __, x: validate_parameter_names(x), + ], ) - """The list of parameters of the subspace.""" + """The parameters spanning the subspace.""" - exp_rep: pd.DataFrame = field(eq=eq_dataframe) + # TODO: When dropping the `exp_rep` parameter from the constructor, + # add a ()-default for `parameters` and declare `from_product` as an alias + # for `__init__` in the docstring (see `SubspaceContinuous` for reference) + exp_rep: pd.DataFrame = field(validator=instance_of(pd.DataFrame), eq=eq_dataframe) """The experimental representation of the subspace.""" - empty_encoding: bool = field(default=False) - """Flag encoding whether an empty encoding is used.""" + _empty_encoding: Annotated[bool, cattrs.override(omit=True)] = field( + alias="empty_encoding", default=None, validator=_deprecate_argument(error=False) + ) + "Ignore! For backwards compatibility only." constraints: tuple[DiscreteConstraint, ...] = field( converter=to_tuple, factory=tuple ) - """A list of constraints for restricting the space.""" + """Optional constraints filtering the subspace.""" - comp_rep: pd.DataFrame = field(eq=eq_dataframe) - """The computational representation of the space. Technically not required but added - as an optional initializer argument to allow ingestion from e.g. serialized objects - and thereby speed up construction. If not provided, the default hook will derive it - from ``exp_rep``.""" + _comp_rep: Annotated[pd.DataFrame, cattrs.override(omit=True)] = field( + alias="comp_rep", default=None, validator=_deprecate_argument(error=True) + ) + "Ignore! For backwards compatibility only." @override def __str__(self) -> str: @@ -145,11 +172,6 @@ def _validate_exp_rep( # noqa: DOC101, DOC103 "This is not allowed, as it can lead to hard-to-detect bugs." ) - @comp_rep.default - def _default_comp_rep(self) -> pd.DataFrame: - """Create the default computational representation.""" - return self.transform(self.exp_rep) - def to_searchspace(self) -> SearchSpace: """Turn the subspace into a search space with no continuous part.""" from baybe.searchspace.core import SearchSpace @@ -178,7 +200,7 @@ def from_product( cls, parameters: Sequence[DiscreteParameter], constraints: Sequence[DiscreteConstraint] | None = None, - empty_encoding: bool = False, + empty_encoding: bool | None = None, ) -> SubspaceDiscrete: """See :class:`baybe.searchspace.core.SearchSpace`.""" # Set defaults and order constraints @@ -214,7 +236,7 @@ def from_dataframe( cls, df: pd.DataFrame, parameters: Sequence[DiscreteParameter] | None = None, - empty_encoding: bool = False, + empty_encoding: bool | None = None, ) -> SubspaceDiscrete: """Create a discrete subspace with a specified set of configurations. @@ -231,7 +253,7 @@ def from_dataframe( fallback. For both types, default values are used for their optional arguments. For more details, see :func:`baybe.parameters.utils.get_parameters_from_dataframe`. - empty_encoding: See :func:`baybe.searchspace.core.SearchSpace.from_product`. + empty_encoding: Ignore! For backwards compatibility only. Returns: The created discrete subspace. @@ -511,12 +533,14 @@ def parameter_names(self) -> tuple[str, ...]: """Return tuple of parameter names.""" return tuple(p.name for p in self.parameters) + @cached_property + def comp_rep(self) -> pd.DataFrame: + """The computational representation of the subspace.""" + return self.transform(self.exp_rep) + @property def comp_rep_columns(self) -> tuple[str, ...]: """The columns spanning the computational representation.""" - # We go via `comp_rep` here instead of using the columns of the individual - # parameters because the search space potentially uses only a subset of the - # columns due to decorrelation return tuple(self.comp_rep.columns) @property @@ -601,24 +625,12 @@ def transform( df, self.parameters, allow_missing=allow_missing, allow_extra=allow_extra ) - # If the transformed values are not required, return an empty dataframe - if self.empty_encoding or len(df) < 1: - return pd.DataFrame(index=df.index) - # Transform the parameters dfs = [] for param in parameters: comp_df = param.transform(df[param.name]) dfs.append(comp_df) - comp_rep = pd.concat(dfs, axis=1) if dfs else pd.DataFrame() - - # If the computational representation has already been built (with potentially - # removing some columns, e.g. due to decorrelation or dropping constant ones), - # any subsequent transformation should yield the same columns. - try: - return comp_rep[self.comp_rep.columns] - except AttributeError: - return comp_rep + return pd.concat(dfs, axis=1) if dfs else pd.DataFrame() def get_parameters_by_name( self, names: Sequence[str] diff --git a/tests/constraints/test_cardinality_constraint_continuous.py b/tests/constraints/test_cardinality_constraint_continuous.py index 2717ceff42..4b8d3f0215 100644 --- a/tests/constraints/test_cardinality_constraint_continuous.py +++ b/tests/constraints/test_cardinality_constraint_continuous.py @@ -104,9 +104,7 @@ def test_sampling_cardinality_constraint(cardinality_bounds: tuple[int, int]): ), ) - subspace_continous = SubspaceContinuous( - parameters=parameters, constraints_nonlin=constraints - ) + subspace_continous = SubspaceContinuous(parameters, constraints) with warnings.catch_warnings(record=True) as w: samples = subspace_continous.sample_uniform(BATCH_SIZE) @@ -155,7 +153,7 @@ def test_polytope_sampling_with_cardinality_constraint(): min_cardinality=MIN_CARDINALITY, ), ] - subspace_continous = SubspaceContinuous.from_product(parameters, constraints) + subspace_continous = SubspaceContinuous(parameters, constraints) with warnings.catch_warnings(record=True) as w: samples = subspace_continous.sample_uniform(BATCH_SIZE) @@ -269,7 +267,7 @@ def test_empty_constraints_after_cardinality_constraint(): min_cardinality=1, ), ] - subspace = SubspaceContinuous.from_product(parameters, constraints) + subspace = SubspaceContinuous(parameters, constraints) subspace.sample_uniform(1) diff --git a/tests/test_campaign.py b/tests/test_campaign.py index 07776afb63..bce68c438c 100644 --- a/tests/test_campaign.py +++ b/tests/test_campaign.py @@ -117,6 +117,9 @@ def test_candidate_toggling(constraints, exclude, complement): assert all(target == exclude) # must contain the updated values assert all(other != exclude) # must contain the original values + # Assert that recommendation with toggled candidates still works + campaign.recommend(1) + @pytest.mark.parametrize( "flag", diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index b1961d31a6..1a7c222b39 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -20,11 +20,13 @@ ContinuousLinearInequalityConstraint, ) from baybe.constraints.base import Constraint +from baybe.constraints.continuous import ContinuousCardinalityConstraint from baybe.exceptions import DeprecationError from baybe.objectives.desirability import DesirabilityObjective from baybe.objectives.single import SingleTargetObjective from baybe.parameters.enum import SubstanceEncoding from baybe.parameters.numerical import ( + NumericalContinuousParameter, NumericalDiscreteParameter, ) from baybe.recommenders.meta.sequential import TwoPhaseMetaRecommender @@ -32,6 +34,7 @@ BotorchRecommender, ) from baybe.recommenders.pure.nonpredictive.sampling import RandomRecommender +from baybe.searchspace.continuous import SubspaceContinuous from baybe.searchspace.discrete import SubspaceDiscrete from baybe.searchspace.validation import get_transform_parameters from baybe.settings import Settings @@ -575,3 +578,112 @@ def test_deprecated_cache_environment_variables(monkeypatch, value: str, expecte DeprecationWarning, match="'BAYBE_CACHE_DIR' has been deprecated" ): assert Settings(restore_environment=True).cache_directory == expected + + +@pytest.mark.parametrize("positional", [True, False]) +def test_deprecated_constraints_arguments(positional): + """Using the deprecated subspace constraint arguments raises a warning.""" + p = NumericalContinuousParameter("p", (0, 1)) + c = ContinuousLinearConstraint(["p"], "=", [0], 0) + c_lin_eq = ContinuousLinearConstraint(["p"], "=", [1], 0) + c_lin_ineq = ContinuousLinearConstraint(["p"], ">=", [1], 0) + c_nonlin = ContinuousCardinalityConstraint(["p"], 1) + + with pytest.warns(DeprecationWarning): + if positional: + subspace = SubspaceContinuous( + parameters=(p,), + constraints=(c,), + constraints_lin_eq=(c_lin_eq,), + constraints_lin_ineq=(c_lin_ineq,), + constraints_nonlin=(c_nonlin,), + ) + else: + subspace = SubspaceContinuous( + (p,), + (c, c_lin_eq), + (c_lin_ineq,), + (c_nonlin,), + ) + + assert c in subspace.constraints + assert c_lin_eq in subspace.constraints + assert c_lin_ineq in subspace.constraints + assert c_nonlin in subspace.constraints + + +def test_deprecated_constraints_arguments_deserialization(): + """Deserialization from legacy JSON with deprecated constraint attributes works.""" + p1 = NumericalContinuousParameter("p", (0, 1)) + c_lin_eq = ContinuousLinearConstraint(["p"], "=", [1], 1) + c_lin_ineq = ContinuousLinearConstraint(["p"], ">=", [1], 0) + c_nonlin = ContinuousCardinalityConstraint(["p"], 1) + + # Construct the expected object using the modern interface + expected = SubspaceContinuous( + parameters=(p1,), + constraints=(c_lin_eq, c_lin_ineq, c_nonlin), + ) + + # Build a legacy dict with the deprecated constraint field names + legacy_dict = { + "type": "SubspaceContinuous", + "parameters": [p1.to_dict()], + "constraints_lin_eq": [c_lin_eq.to_dict()], + "constraints_lin_ineq": [c_lin_ineq.to_dict()], + "constraints_nonlin": [c_nonlin.to_dict()], + } + + with pytest.warns(DeprecationWarning): + actual = SubspaceContinuous.from_dict(legacy_dict) + + assert actual == expected + + +@pytest.mark.parametrize( + ("arg", "error"), [("empty_encoding", False), ("comp_rep", True)] +) +def test_deprecated_subspace_discrete_arguments(arg, error): + """Providing deprecated arguments to `SubspaceDiscrete` raises an error.""" + context = ( + pytest.raises(DeprecationError, match=f"Providing '{arg}'") + if error + else pytest.warns(DeprecationWarning, match=f"Providing '{arg}'") + ) + with context: + SubspaceDiscrete( + parameters=[], constraints=[], exp_rep=pd.DataFrame(), **{arg: 0} + ) + + +def test_deprecated_empty_encoding_from_product(): + """Passing `empty_encoding` to `SubspaceDiscrete.from_product` raises an error.""" + with pytest.warns(DeprecationWarning, match="Providing 'empty_encoding'"): + SubspaceDiscrete.from_product( + parameters=[NumericalDiscreteParameter("p", [0, 1])], + empty_encoding=True, + ) + + +def test_deprecated_empty_encoding_from_dataframe(): + """Passing `empty_encoding` to `SubspaceDiscrete.from_dataframe` raises an error.""" + with pytest.warns(DeprecationWarning, match="Providing 'empty_encoding'"): + SubspaceDiscrete.from_dataframe( + parameters=[NumericalDiscreteParameter("p", [0, 1])], + df=pd.DataFrame({"p": [0, 1]}), + empty_encoding=True, + ) + + +def test_deprecated_discrete_subspace_deserialization(): + """Deserialization from legacy JSON with `empty_encoding`/`comp_rep` works.""" + p = NumericalDiscreteParameter("p", [0, 1]) + expected = SubspaceDiscrete.from_product(parameters=[p]) + + # Build a legacy dict containing the deprecated fields + legacy_dict = expected.to_dict() + legacy_dict["empty_encoding"] = False + legacy_dict["comp_rep"] = legacy_dict["exp_rep"] + + actual = SubspaceDiscrete.from_dict(legacy_dict) + assert actual == expected diff --git a/tests/test_searchspace.py b/tests/test_searchspace.py index 7077acbcf5..6bfc5303a8 100644 --- a/tests/test_searchspace.py +++ b/tests/test_searchspace.py @@ -7,10 +7,7 @@ from baybe._optional.info import POLARS_INSTALLED from baybe.constraints import ( - ContinuousCardinalityConstraint, ContinuousLinearConstraint, - DiscreteSumConstraint, - ThresholdCondition, ) from baybe.exceptions import ( EmptySearchSpaceError, @@ -121,24 +118,6 @@ def test_discrete_from_dataframe_dtype_consistency(): assert pd.api.types.is_float_dtype(subspace.exp_rep["C"]) -def test_invalid_simplex_creating_with_overlapping_parameters(): - """Creating a simplex searchspace with overlapping simplex and product parameters - raises an error.""" # noqa - parameters = [NumericalDiscreteParameter(name="x_1", values=(0, 1, 2))] - - with pytest.raises( - ValueError, - match="'simplex_parameters' and 'product_parameters' must be disjoint", - ): - SearchSpace( - SubspaceDiscrete.from_simplex( - max_sum=1.0, - simplex_parameters=parameters, - product_parameters=parameters, - ) - ) - - def test_continuous_searchspace_creation_from_bounds(): """A purely continuous search space is created from example bounds.""" parameters = ( @@ -174,77 +153,6 @@ def test_hyperrectangle_searchspace_creation(): assert searchspace.parameters == parameters -def test_invalid_constraint_parameter_combos(): - """Testing invalid constraint-parameter combinations.""" - parameters = [ - CategoricalParameter("cat1", values=("c1", "c2")), - NumericalDiscreteParameter("d1", values=[1, 2, 3]), - NumericalDiscreteParameter("d2", values=[0, 1, 2]), - NumericalContinuousParameter("c1", (0, 2)), - NumericalContinuousParameter("c2", (-1, 1)), - ] - - # Attempting continuous constraint over hybrid parameter set - with pytest.raises(ValueError): - SearchSpace.from_product( - parameters=parameters, - constraints=[ContinuousLinearConstraint(["c1", "c2", "d1"], "=")], - ) - - # Attempting continuous constraint over hybrid parameter set - with pytest.raises(ValueError): - SearchSpace.from_product( - parameters=parameters, - constraints=[ContinuousLinearConstraint(["c1", "c2", "d1"], "=")], - ) - - # Attempting discrete constraint over hybrid parameter set - with pytest.raises(ValueError): - SearchSpace.from_product( - parameters=parameters, - constraints=[ - DiscreteSumConstraint( - parameters=["d1", "d2", "c1"], - condition=ThresholdCondition(threshold=1.0, operator=">"), - ) - ], - ) - - # Attempting constraints over parameter set where a parameter does not exist - with pytest.raises(ValueError): - SearchSpace.from_product( - parameters=parameters, - constraints=[ - DiscreteSumConstraint( - parameters=["d1", "e7", "c1"], - condition=ThresholdCondition(threshold=1.0, operator=">"), - ) - ], - ) - - # Attempting constraints over parameter set where a parameter does not exist - with pytest.raises(ValueError): - SearchSpace.from_product( - parameters=parameters, - constraints=[ContinuousLinearConstraint(["c1", "e7", "d1"], "=")], - ) - - # Attempting constraints over parameter sets containing non-numerical discrete - # parameters. - with pytest.raises( - ValueError, match="valid only for numerical discrete parameters" - ): - SearchSpace.from_product( - parameters=parameters, - constraints=[ - DiscreteSumConstraint( - parameters=["cat1", "d1", "d2"], - condition=ThresholdCondition(threshold=1.0, operator=">"), - ) - ], - ) - - @pytest.mark.parametrize( "parameter_names", [ @@ -295,48 +203,6 @@ def test_searchspace_memory_estimate(searchspace: SearchSpace): ) -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, - ), - ), - ) - - -def test_cardinality_constraint_with_invalid_parameter_bounds(): - """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)), - ) - with pytest.raises(ValueError, match="must include zero"): - SubspaceContinuous( - parameters=parameters, - constraints_nonlin=( - ContinuousCardinalityConstraint( - parameters=["c1", "c2"], - max_cardinality=1, - ), - ), - ) - - @pytest.mark.skipif( not POLARS_INSTALLED, reason="Optional polars dependency not installed." ) @@ -430,14 +296,16 @@ def test_task_parameter_active_values_validation(): [ ( ["InterConstraint_3"], - lambda samples: samples["Conti_finite1"].sum() - + 2 * samples["Conti_finite2"].sum(), + lambda samples: ( + samples["Conti_finite1"].sum() + 2 * samples["Conti_finite2"].sum() + ), lambda result: np.isclose(result, 0.3, atol=1e-6), ), ( ["InterConstraint_4"], - lambda samples: 2 * samples["Conti_finite1"].sum() - - samples["Conti_finite2"].sum(), + lambda samples: ( + 2 * samples["Conti_finite1"].sum() - samples["Conti_finite2"].sum() + ), lambda result: result >= 0.3 - 1e-6, ), ], @@ -485,8 +353,7 @@ def test_sample_from_polytope_mixed_constraints_with_interpoint(): subspace = SubspaceContinuous( parameters=parameters, - constraints_lin_ineq=[regular_constraint], - constraints_lin_eq=[interpoint_constraint], + constraints=[regular_constraint, interpoint_constraint], ) assert subspace.has_interpoint_constraints diff --git a/tests/validation/test_searchspace_validation.py b/tests/validation/test_searchspace_validation.py index c18e56c24e..bdd154c5a1 100644 --- a/tests/validation/test_searchspace_validation.py +++ b/tests/validation/test_searchspace_validation.py @@ -4,7 +4,20 @@ import pytest from pytest import param -from baybe.parameters.numerical import NumericalDiscreteParameter +from baybe.constraints import ( + ContinuousCardinalityConstraint, + ContinuousLinearConstraint, + DiscreteSumConstraint, + ThresholdCondition, +) +from baybe.constraints.discrete import DiscreteLinkedParametersConstraint +from baybe.exceptions import IncompatibilityError +from baybe.parameters import ( + CategoricalParameter, + NumericalContinuousParameter, + NumericalDiscreteParameter, +) +from baybe.searchspace import SearchSpace, SubspaceContinuous, SubspaceDiscrete from baybe.utils.dataframe import get_transform_objects parameters = [NumericalDiscreteParameter("d1", [0, 1])] @@ -42,3 +55,181 @@ def test_invalid_transforms(df, match): def test_valid_transforms(df, missing, extra): """When providing the appropriate flags, the columns of the dataframe to be transformed can be flexibly chosen.""" # noqa get_transform_objects(df, parameters, allow_missing=missing, allow_extra=extra) + + +def test_invalid_constraint_parameter_combos(): + """Testing invalid constraint-parameter combinations.""" + parameters = [ + CategoricalParameter("cat1", values=("c1", "c2")), + NumericalDiscreteParameter("d1", values=[1, 2, 3]), + NumericalDiscreteParameter("d2", values=[0, 1, 2]), + NumericalContinuousParameter("c1", (0, 2)), + NumericalContinuousParameter("c2", (-1, 1)), + ] + + # Attempting continuous constraint over hybrid parameter set + with pytest.raises(ValueError): + SearchSpace.from_product( + parameters=parameters, + constraints=[ContinuousLinearConstraint(["c1", "c2", "d1"], "=")], + ) + + # Attempting continuous constraint over hybrid parameter set + with pytest.raises(ValueError): + SearchSpace.from_product( + parameters=parameters, + constraints=[ContinuousLinearConstraint(["c1", "c2", "d1"], "=")], + ) + + # Attempting discrete constraint over hybrid parameter set + with pytest.raises(ValueError): + SearchSpace.from_product( + parameters=parameters, + constraints=[ + DiscreteSumConstraint( + parameters=["d1", "d2", "c1"], + condition=ThresholdCondition(threshold=1.0, operator=">"), + ) + ], + ) + + # Attempting constraints over parameter set where a parameter does not exist + with pytest.raises(ValueError): + SearchSpace.from_product( + parameters=parameters, + constraints=[ + DiscreteSumConstraint( + parameters=["d1", "e7", "c1"], + condition=ThresholdCondition(threshold=1.0, operator=">"), + ) + ], + ) + + # Attempting constraints over parameter set where a parameter does not exist + with pytest.raises(ValueError): + SearchSpace.from_product( + parameters=parameters, + constraints=[ContinuousLinearConstraint(["c1", "e7", "d1"], "=")], + ) + + # Attempting constraints over parameter sets containing non-numerical discrete + # parameters. + with pytest.raises( + ValueError, match="valid only for numerical discrete parameters" + ): + SearchSpace.from_product( + parameters=parameters, + constraints=[ + DiscreteSumConstraint( + parameters=["cat1", "d1", "d2"], + condition=ThresholdCondition(threshold=1.0, operator=">"), + ) + ], + ) + + +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=( + ContinuousCardinalityConstraint( + parameters=["c1", "c2"], + max_cardinality=1, + ), + ContinuousCardinalityConstraint( + parameters=["c2", "c3"], + max_cardinality=1, + ), + ), + ) + + +def test_cardinality_constraint_with_invalid_parameter_bounds(): + """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)), + ) + with pytest.raises(ValueError, match="must include zero"): + SubspaceContinuous( + parameters=parameters, + constraints=( + ContinuousCardinalityConstraint( + parameters=["c1", "c2"], + max_cardinality=1, + ), + ), + ) + + +p_cont = NumericalContinuousParameter("p", (0, 1)) +p_disc = NumericalDiscreteParameter("p", (0, 1)) + + +@pytest.mark.parametrize( + ("p1", "p2", "space"), + [ + param(p_cont, p_cont, SubspaceContinuous, id="continuous"), + param(p_disc, p_disc, SubspaceDiscrete, id="discrete"), + param(p_cont, p_disc, SearchSpace, id="hybrid"), + ], +) +def test_subspace_with_duplicate_parameter_names(p1, p2, space): + """Creating a search space with duplicate parameter names raises an error.""" + with pytest.raises(ValueError, match="unique names"): + space.from_product(parameters=[p1, p2]) + + +@pytest.mark.parametrize("discrete", [True, False]) +@pytest.mark.parametrize( + "referenced", + [ + param(["nonexistent"], id="all_nonexistent"), + param(["p1", "nonexistent"], id="partially_nonexistent"), + ], +) +def test_continuous_subspace_constraint_with_nonexistent_params(referenced, discrete): + """Using constraints referencing nonexistent parameters raises an error.""" + if discrete: + parameters = [ + NumericalDiscreteParameter("p1", (0, 1)), + NumericalDiscreteParameter("p2", (0, 1)), + ] + space = SubspaceDiscrete + constraint = DiscreteLinkedParametersConstraint(referenced) + else: + parameters = [ + NumericalContinuousParameter("p1", (0, 1)), + NumericalContinuousParameter("p2", (0, 1)), + ] + space = SubspaceContinuous + constraint = ContinuousLinearConstraint(referenced, "=") + + with pytest.raises(IncompatibilityError, match="not part of the subspace"): + space.from_product(parameters=parameters, constraints=[constraint]) + + +def test_invalid_simplex_creation_with_overlapping_parameters(): + """Creating a simplex searchspace with overlapping simplex and product parameters + raises an error.""" # noqa + parameters = [NumericalDiscreteParameter(name="x_1", values=(0, 1, 2))] + + with pytest.raises( + ValueError, + match="'simplex_parameters' and 'product_parameters' must be disjoint", + ): + SearchSpace( + SubspaceDiscrete.from_simplex( + max_sum=1.0, + simplex_parameters=parameters, + product_parameters=parameters, + ) + )