diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f714a36..df5ec03b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Changed +- Passing an `Objective` to `Campaign` is now optional + +### Breaking Changes +- Providing an explicit `batch_size` is now mandatory when asking for recommendations + ## [0.9.1] - 2024-06-04 ### Changed - Discrete searchspace memory estimate is now natively represented in bytes @@ -52,6 +59,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Environment variables `BAYBE_NUMPY_USE_SINGLE_PRECISION` and `BAYBE_TORCH_USE_SINGLE_PRECISION` to enforce single point precision usage +### Breaking Changes +- `RecommenderProtocol.recommend` now accepts an optional `Objective` +- `RecommenderProtocol.recommend` now expects training data to be provided as a single + dataframe in experimental representation instead of two separate dataframes in + computational representation + ### Removed - `model_params` attribute from `Surrogate` base class, `GaussianProcessSurrogate` and `CustomONNXSurrogate` diff --git a/baybe/campaign.py b/baybe/campaign.py index ec3585f98..b26904bb1 100644 --- a/baybe/campaign.py +++ b/baybe/campaign.py @@ -3,11 +3,13 @@ from __future__ import annotations import json +from typing import Optional import cattrs import numpy as np import pandas as pd from attrs import define, field +from attrs.converters import optional from baybe.exceptions import DeprecationError from baybe.objectives.base import Objective, to_objective @@ -47,7 +49,9 @@ class Campaign(SerialMixin): searchspace: SearchSpace = field() """The search space in which the experiments are conducted.""" - objective: Objective = field(converter=to_objective) + objective: Optional[Objective] = field( + default=None, converter=optional(to_objective) + ) """The optimization objective. When passing a single :class:`baybe.targets.base.Target`, it gets automatically wrapped into a :class:`baybe.objectives.single.SingleTargetObjective`.""" @@ -127,21 +131,7 @@ def parameters(self) -> tuple[Parameter, ...]: @property def targets(self) -> tuple[Target, ...]: """The targets of the underlying objective.""" - return self.objective.targets - - @property - def _measurements_parameters_comp(self) -> pd.DataFrame: - """The computational representation of the measured parameters.""" - if len(self._measurements_exp) < 1: - return pd.DataFrame() - return self.searchspace.transform(self._measurements_exp) - - @property - def _measurements_targets_comp(self) -> pd.DataFrame: - """The computational representation of the measured targets.""" - if len(self._measurements_exp) < 1: - return pd.DataFrame() - return self.objective.transform(self._measurements_exp) + return self.objective.targets if self.objective is not None else () @classmethod def from_config(cls, config_json: str) -> Campaign: @@ -258,7 +248,7 @@ def add_measurements( def recommend( self, - batch_size: int = 5, + batch_size: int, batch_quantity: int = None, # type: ignore[assignment] ) -> pd.DataFrame: """Provide the recommendations for the next batch of experiments. @@ -298,10 +288,10 @@ def recommend( # Get the recommended search space entries rec = self.recommender.recommend( - self.searchspace, batch_size, - self._measurements_parameters_comp, - self._measurements_targets_comp, + self.searchspace, + self.objective, + self._measurements_exp, ) # Cache the recommendations diff --git a/baybe/recommenders/base.py b/baybe/recommenders/base.py index 97c75c742..93d14821e 100644 --- a/baybe/recommenders/base.py +++ b/baybe/recommenders/base.py @@ -5,6 +5,7 @@ import cattrs import pandas as pd +from baybe.objectives.base import Objective from baybe.recommenders.deprecation import structure_recommender_protocol from baybe.searchspace import SearchSpace from baybe.serialization import converter, unstructure_base @@ -15,21 +16,29 @@ class RecommenderProtocol(Protocol): def recommend( self, - searchspace: SearchSpace, batch_size: int, - train_x: Optional[pd.DataFrame], - train_y: Optional[pd.DataFrame], + searchspace: SearchSpace, + objective: Optional[Objective], + measurements: Optional[pd.DataFrame], ) -> pd.DataFrame: """Recommend a batch of points from the given search space. Args: - searchspace: The search space from which to recommend the points. batch_size: The number of points to be recommended. - train_x: Optional training inputs for training a model. - train_y: Optional training labels for training a model. + searchspace: The search space from which to recommend the points. + objective: An optional objective to be optimized. + measurements: Optional experimentation data that can be used for model + training. The data is to be provided in "experimental representation": + It needs to contain one column for each parameter spanning the search + space (column name matching the parameter name) and one column for each + target tracked by the objective (column name matching the target name). + Each row corresponds to one conducted experiment, where the parameter + columns define the experimental setting and the target columns report + the measured outcomes. Returns: - A dataframe containing the recommendations as individual rows. + A dataframe containing the recommendations in experimental representation + as individual rows. """ ... diff --git a/baybe/recommenders/meta/base.py b/baybe/recommenders/meta/base.py index 463bafe4b..978bbd791 100644 --- a/baybe/recommenders/meta/base.py +++ b/baybe/recommenders/meta/base.py @@ -8,6 +8,7 @@ from attrs import define, field from baybe.exceptions import DeprecationError +from baybe.objectives.base import Objective from baybe.recommenders.base import RecommenderProtocol from baybe.recommenders.deprecation import structure_recommender_protocol from baybe.recommenders.pure.base import PureRecommender @@ -50,20 +51,22 @@ def _validate_allow_recommending_already_measured(self, _, value): @abstractmethod def select_recommender( self, + batch_size: int, searchspace: SearchSpace, - batch_size: int = 1, - train_x: Optional[pd.DataFrame] = None, - train_y: Optional[pd.DataFrame] = None, + objective: Optional[Objective] = None, + measurements: Optional[pd.DataFrame] = None, ) -> PureRecommender: """Select a pure recommender for the given experimentation context. Args: + batch_size: + See :func:`baybe.recommenders.meta.base.MetaRecommender.recommend`. searchspace: See :func:`baybe.recommenders.meta.base.MetaRecommender.recommend`. - batch_size: + objective: + See :func:`baybe.recommenders.meta.base.MetaRecommender.recommend`. + measurements: See :func:`baybe.recommenders.meta.base.MetaRecommender.recommend`. - train_x: See :func:`baybe.recommenders.meta.base.MetaRecommender.recommend`. - train_y: See :func:`baybe.recommenders.meta.base.MetaRecommender.recommend`. Returns: The selected recommender. @@ -71,14 +74,24 @@ def select_recommender( def recommend( self, + batch_size: int, searchspace: SearchSpace, - batch_size: int = 1, - train_x: Optional[pd.DataFrame] = None, - train_y: Optional[pd.DataFrame] = None, + objective: Optional[Objective] = None, + measurements: Optional[pd.DataFrame] = None, ) -> pd.DataFrame: """See :func:`baybe.recommenders.base.RecommenderProtocol.recommend`.""" - recommender = self.select_recommender(searchspace, batch_size, train_x, train_y) - return recommender.recommend(searchspace, batch_size, train_x, train_y) + recommender = self.select_recommender( + batch_size=batch_size, + searchspace=searchspace, + objective=objective, + measurements=measurements, + ) + return recommender.recommend( + batch_size=batch_size, + searchspace=searchspace, + objective=objective, + measurements=measurements, + ) # Register (un-)structure hooks diff --git a/baybe/recommenders/meta/sequential.py b/baybe/recommenders/meta/sequential.py index 9d4cebdff..f345f7804 100644 --- a/baybe/recommenders/meta/sequential.py +++ b/baybe/recommenders/meta/sequential.py @@ -11,12 +11,12 @@ from attrs.validators import deep_iterable, in_, instance_of from baybe.exceptions import NoRecommendersLeftError +from baybe.objectives.base import Objective from baybe.recommenders.meta.base import MetaRecommender from baybe.recommenders.pure.base import PureRecommender from baybe.recommenders.pure.bayesian.sequential_greedy import ( SequentialGreedyRecommender, ) -from baybe.recommenders.pure.nonpredictive.base import NonPredictiveRecommender from baybe.recommenders.pure.nonpredictive.sampling import RandomRecommender from baybe.searchspace import SearchSpace from baybe.serialization import ( @@ -25,12 +25,6 @@ converter, ) -# TODO: Make bayesian recommenders handle empty training data -_unsupported_recommender_error = ValueError( - f"For cases where no training is available, the selected recommender " - f"must be a subclass of '{NonPredictiveRecommender.__name__}'." -) - @define class TwoPhaseMetaRecommender(MetaRecommender): @@ -59,22 +53,16 @@ class TwoPhaseMetaRecommender(MetaRecommender): def select_recommender( # noqa: D102 self, - searchspace: SearchSpace, - batch_size: int = 1, - train_x: Optional[pd.DataFrame] = None, - train_y: Optional[pd.DataFrame] = None, + batch_size: int, + searchspace: Optional[SearchSpace] = None, + objective: Optional[Objective] = None, + measurements: Optional[pd.DataFrame] = None, ) -> PureRecommender: # See base class. - # TODO: enable bayesian recommenders for empty training data - if (train_x is None or len(train_x) == 0) and not isinstance( - self.initial_recommender, NonPredictiveRecommender - ): - raise _unsupported_recommender_error - return ( self.recommender - if len(train_x) >= self.switch_after + if (measurements is not None) and (len(measurements) >= self.switch_after) else self.initial_recommender ) @@ -95,6 +83,8 @@ class SequentialMetaRecommender(MetaRecommender): instead. Raises: + RuntimeError: If the training dataset size decreased compared to the previous + call. NoRecommendersLeftError: If more recommenders are requested than there are recommenders available and ``mode="raise"``. """ @@ -134,21 +124,24 @@ class SequentialMetaRecommender(MetaRecommender): def select_recommender( # noqa: D102 self, - searchspace: SearchSpace, - batch_size: int = 1, - train_x: Optional[pd.DataFrame] = None, - train_y: Optional[pd.DataFrame] = None, + batch_size: int, + searchspace: Optional[SearchSpace] = None, + objective: Optional[Objective] = None, + measurements: Optional[pd.DataFrame] = None, ) -> PureRecommender: # See base class. + n_data = len(measurements) if measurements is not None else 0 + # If the training dataset size has increased, move to the next recommender - if len(train_x) > self._n_last_measurements: + if n_data > self._n_last_measurements: self._step += 1 + # If the training dataset size has decreased, something went wrong - elif len(train_x) < self._n_last_measurements: + elif n_data < self._n_last_measurements: raise RuntimeError( f"The training dataset size decreased from {self._n_last_measurements} " - f"to {len(train_x)} since the last function call, which indicates that " + f"to {n_data} since the last function call, which indicates that " f"'{self.__class__.__name__}' was not used as intended." ) @@ -169,13 +162,7 @@ def select_recommender( # noqa: D102 ) from ex # Remember the training dataset size for the next call - self._n_last_measurements = len(train_x) - - # TODO: enable bayesian recommenders for empty training data - if (train_x is None or len(train_x) == 0) and not isinstance( - recommender, NonPredictiveRecommender - ): - raise _unsupported_recommender_error + self._n_last_measurements = n_data return recommender @@ -219,24 +206,26 @@ def default_iterator(self): def select_recommender( # noqa: D102 self, - searchspace: SearchSpace, - batch_size: int = 1, - train_x: Optional[pd.DataFrame] = None, - train_y: Optional[pd.DataFrame] = None, + batch_size: int, + searchspace: Optional[SearchSpace] = None, + objective: Optional[Objective] = None, + measurements: Optional[pd.DataFrame] = None, ) -> PureRecommender: # See base class. use_last = True + n_data = len(measurements) if measurements is not None else 0 # If the training dataset size has increased, move to the next recommender - if len(train_x) > self._n_last_measurements: + if n_data > self._n_last_measurements: self._step += 1 use_last = False + # If the training dataset size has decreased, something went wrong - elif len(train_x) < self._n_last_measurements: + elif n_data < self._n_last_measurements: raise RuntimeError( f"The training dataset size decreased from {self._n_last_measurements} " - f"to {len(train_x)} since the last function call, which indicates that " + f"to {n_data} since the last function call, which indicates that " f"'{self.__class__.__name__}' was not used as intended." ) @@ -251,13 +240,7 @@ def select_recommender( # noqa: D102 ) from ex # Remember the training dataset size for the next call - self._n_last_measurements = len(train_x) - - # TODO: enable bayesian recommenders for empty training data - if (train_x is None or len(train_x) == 0) and not isinstance( - self._last_recommender, NonPredictiveRecommender - ): - raise _unsupported_recommender_error + self._n_last_measurements = n_data return self._last_recommender # type: ignore[return-value] diff --git a/baybe/recommenders/naive.py b/baybe/recommenders/naive.py index b09b7b7c0..aaa49d861 100644 --- a/baybe/recommenders/naive.py +++ b/baybe/recommenders/naive.py @@ -6,6 +6,7 @@ import pandas as pd from attrs import define, evolve, field, fields +from baybe.objectives.base import Objective from baybe.recommenders.pure.base import PureRecommender from baybe.recommenders.pure.bayesian.base import BayesianRecommender from baybe.recommenders.pure.bayesian.sequential_greedy import ( @@ -26,6 +27,8 @@ class NaiveHybridSpaceRecommender(PureRecommender): a non-hybrid space, it uses the corresponding recommender. """ + # TODO: Cleanly implement naive recommender using fixed parameter class + # Class variables compatibility: ClassVar[SearchSpaceType] = SearchSpaceType.HYBRID # See base class. @@ -77,10 +80,10 @@ def __attrs_post_init__(self): def recommend( # noqa: D102 self, + batch_size: int, searchspace: SearchSpace, - batch_size: int = 1, - train_x: Optional[pd.DataFrame] = None, - train_y: Optional[pd.DataFrame] = None, + objective: Optional[Objective] = None, + measurements: Optional[pd.DataFrame] = None, ) -> pd.DataFrame: # See base class. @@ -103,10 +106,10 @@ def recommend( # noqa: D102 degenerate_recommender = self.cont_recommender if degenerate_recommender is not None: return degenerate_recommender.recommend( - searchspace=searchspace, batch_size=batch_size, - train_x=train_x, - train_y=train_y, + searchspace=searchspace, + objective=objective, + measurements=measurements, ) # We are in a hybrid setting now @@ -129,7 +132,9 @@ def recommend( # noqa: D102 # We now check whether the discrete recommender is bayesian. if isinstance(self.disc_recommender, BayesianRecommender): # Get access to the recommenders acquisition function - self.disc_recommender._setup_botorch_acqf(searchspace, train_x, train_y) + self.disc_recommender._setup_botorch_acqf( + searchspace, objective, measurements + ) # Construct the partial acquisition function that attaches cont_part # whenever evaluating the acquisition function @@ -154,7 +159,7 @@ def recommend( # noqa: D102 disc_part_tensor = to_tensor(disc_part).unsqueeze(-2) # Setup a fresh acquisition function for the continuous recommender - self.cont_recommender._setup_botorch_acqf(searchspace, train_x, train_y) + self.cont_recommender._setup_botorch_acqf(searchspace, objective, measurements) # Construct the continuous space as a standalone space cont_acqf_part = PartialAcquisitionFunction( diff --git a/baybe/recommenders/pure/base.py b/baybe/recommenders/pure/base.py index 7f5b88d1d..e27010451 100644 --- a/baybe/recommenders/pure/base.py +++ b/baybe/recommenders/pure/base.py @@ -7,6 +7,7 @@ from attrs import define, field from baybe.exceptions import NotEnoughPointsLeftError +from baybe.objectives.base import Objective from baybe.recommenders.base import RecommenderProtocol from baybe.searchspace import SearchSpace from baybe.searchspace.continuous import SubspaceContinuous @@ -33,10 +34,10 @@ class PureRecommender(ABC, RecommenderProtocol): def recommend( # noqa: D102 self, + batch_size: int, searchspace: SearchSpace, - batch_size: int = 1, - train_x: Optional[pd.DataFrame] = None, - train_y: Optional[pd.DataFrame] = None, + objective: Optional[Objective] = None, + measurements: Optional[pd.DataFrame] = None, ) -> pd.DataFrame: # See base class if searchspace.type is SearchSpaceType.CONTINUOUS: diff --git a/baybe/recommenders/pure/bayesian/base.py b/baybe/recommenders/pure/bayesian/base.py index 5f2d6794c..65c1f6179 100644 --- a/baybe/recommenders/pure/bayesian/base.py +++ b/baybe/recommenders/pure/bayesian/base.py @@ -10,6 +10,7 @@ from baybe.acquisition.base import AcquisitionFunction from baybe.acquisition.utils import convert_acqf from baybe.exceptions import DeprecationError +from baybe.objectives.base import Objective from baybe.recommenders.pure.base import PureRecommender from baybe.searchspace import SearchSpace from baybe.surrogates import _ONNX_INSTALLED, GaussianProcessSurrogate @@ -50,71 +51,49 @@ def _validate_deprecated_argument(self, _, value) -> None: def _setup_botorch_acqf( self, searchspace: SearchSpace, - train_x: Optional[pd.DataFrame] = None, - train_y: Optional[pd.DataFrame] = None, + objective: Objective, + measurements: pd.DataFrame, ) -> None: - """Create the current acquisition function from provided training data. - - The acquisition function is stored in the private attribute - ``_acquisition_function``. - - Args: - searchspace: The search space in which the experiments are to be conducted. - train_x: The features of the conducted experiments. - train_y: The corresponding response values. - - Raises: - NotImplementedError: If the setup is attempted from empty training data - """ - if train_x is None or train_y is None: - raise NotImplementedError( - "Bayesian recommenders do not support empty training data yet." - ) - - surrogate_model = self._fit(searchspace, train_x, train_y) + """Create the acquisition function for the current training data.""" # noqa: E501 + # TODO: Transition point from dataframe to tensor needs to be refactored. + # Currently, surrogate models operate with tensors, while acquisition + # functions with dataframes. + train_x = searchspace.transform(measurements) + train_y = objective.transform(measurements) + self.surrogate_model._fit(searchspace, *to_tensor(train_x, train_y)) self._botorch_acqf = self.acquisition_function.to_botorch( - surrogate_model, train_x, train_y + self.surrogate_model, train_x, train_y ) - def _fit( - self, - searchspace: SearchSpace, - train_x: pd.DataFrame, - train_y: pd.DataFrame, - ) -> Surrogate: - """Train a fresh surrogate model instance. - - Args: - searchspace: The search space. - train_x: The features of the conducted experiments. - train_y: The corresponding response values. - - Returns: - A surrogate model fitted to the provided data. - - Raises: - ValueError: If the training inputs and targets do not have the same index. - """ - # validate input - if not train_x.index.equals(train_y.index): - raise ValueError("Training inputs and targets must have the same index.") - - self.surrogate_model.fit(searchspace, *to_tensor(train_x, train_y)) - - return self.surrogate_model - def recommend( # noqa: D102 self, + batch_size: int, searchspace: SearchSpace, - batch_size: int = 1, - train_x: Optional[pd.DataFrame] = None, - train_y: Optional[pd.DataFrame] = None, + objective: Optional[Objective] = None, + measurements: Optional[pd.DataFrame] = None, ) -> pd.DataFrame: # See base class. + if objective is None: + raise NotImplementedError( + f"Recommenders of type '{BayesianRecommender.__name__}' require " + f"that an objective is specified." + ) + + if (measurements is None) or (len(measurements) == 0): + raise NotImplementedError( + f"Recommenders of type '{BayesianRecommender.__name__}' do not support " + f"empty training data." + ) + if _ONNX_INSTALLED and isinstance(self.surrogate_model, CustomONNXSurrogate): CustomONNXSurrogate.validate_compatibility(searchspace) - self._setup_botorch_acqf(searchspace, train_x, train_y) + self._setup_botorch_acqf(searchspace, objective, measurements) - return super().recommend(searchspace, batch_size, train_x, train_y) + return super().recommend( + batch_size=batch_size, + searchspace=searchspace, + objective=objective, + measurements=measurements, + ) diff --git a/baybe/recommenders/pure/nonpredictive/base.py b/baybe/recommenders/pure/nonpredictive/base.py index 18a19902d..c44c59c7a 100644 --- a/baybe/recommenders/pure/nonpredictive/base.py +++ b/baybe/recommenders/pure/nonpredictive/base.py @@ -1,12 +1,47 @@ """Base class for all nonpredictive recommenders.""" +import warnings from abc import ABC +from typing import Optional +import pandas as pd from attrs import define +from baybe.objectives.base import Objective from baybe.recommenders.pure.base import PureRecommender +from baybe.searchspace.core import SearchSpace @define class NonPredictiveRecommender(PureRecommender, ABC): """Abstract base class for all nonpredictive recommenders.""" + + def recommend( # noqa: D102 + self, + batch_size: int, + searchspace: SearchSpace, + objective: Optional[Objective] = None, + measurements: Optional[pd.DataFrame] = None, + ) -> pd.DataFrame: + # See base class. + + if (measurements is not None) and (len(measurements) != 0): + warnings.warn( + f"'{self.recommend.__name__}' was called with a non-empty " + f"set of measurements but '{self.__class__.__name__}' does not " + f"utilize any training data, meaning that the argument is ignored.", + UserWarning, + ) + if objective is not None: + warnings.warn( + f"'{self.recommend.__name__}' was called with a an explicit objective " + f"but '{self.__class__.__name__}' does not " + f"consider any objectives, meaning that the argument is ignored.", + UserWarning, + ) + return super().recommend( + batch_size=batch_size, + searchspace=searchspace, + objective=objective, + measurements=measurements, + ) diff --git a/baybe/simulation/core.py b/baybe/simulation/core.py index 873a99feb..36c5a9e33 100644 --- a/baybe/simulation/core.py +++ b/baybe/simulation/core.py @@ -97,6 +97,11 @@ def simulate_experiment( # want to investigate in the future. # TODO: Use a `will_terminate` campaign property to decide if the campaign will # run indefinitely or not, and allow omitting `n_doe_iterations` for the latter. + if campaign.objective is None: + raise ValueError( + "The given campaign has no objective defined, hence there are no targets " + "to be tracked." + ) context = temporary_seed(random_seed) if random_seed is not None else nullcontext() with context: diff --git a/examples/Multi_Target/desirability.py b/examples/Multi_Target/desirability.py index 95849a4d5..a61956d8f 100644 --- a/examples/Multi_Target/desirability.py +++ b/examples/Multi_Target/desirability.py @@ -8,6 +8,8 @@ ### Necessary imports for this example +import pandas as pd + from baybe import Campaign from baybe.objectives import DesirabilityObjective from baybe.parameters import CategoricalParameter, NumericalDiscreteParameter @@ -104,19 +106,16 @@ N_ITERATIONS = 3 for kIter in range(N_ITERATIONS): - print(f"\n\n#### ITERATION {kIter+1} ####") - rec = campaign.recommend(batch_size=3) - print("\nRecommended measurements:\n", rec) - add_fake_results(rec, campaign) - print("\nRecommended measurements with fake measured results:\n", rec) - campaign.add_measurements(rec) + desirability = campaign.objective.transform(campaign.measurements) - print("\n\nInternal measurement dataframe computational representation Y:\n") - print(campaign._measurements_targets_comp) - + print(f"\n\n#### ITERATION {kIter+1} ####") + print("\nRecommended measurements with fake measured results:\n") + print(rec) + print("\nInternal measurement database with desirability values:\n") + print(pd.concat([campaign.measurements, desirability], axis=1)) ### Addendum: Description of `transformation` functions diff --git a/mypy.ini b/mypy.ini index 50c1fd952..53214320a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -10,6 +10,7 @@ exclude = (?x)( | baybe/utils/dataframe.py | baybe/deprecation.py | baybe/exceptions.py + | baybe/recommenders/naive.py | baybe/scaler.py | baybe/simulation.py ) diff --git a/tests/conftest.py b/tests/conftest.py index aeb240e63..cec7d63cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ import os from itertools import chain from typing import Union +from unittest.mock import Mock import numpy as np import pandas as pd @@ -859,22 +860,13 @@ def run_iterations( campaign.add_measurements(rec) -def get_dummy_training_data(length: int) -> tuple[pd.DataFrame, pd.DataFrame]: - """Create column-less input and target dataframes of specified length.""" - df = pd.DataFrame(np.empty((length, 0))) - return df, df - - -def get_dummy_searchspace() -> SearchSpace: - """Create a dummy searchspace whose actual content is irrelevant.""" - parameters = [NumericalDiscreteParameter(name="test", values=(0, 1))] - return SearchSpace.from_product(parameters) - - def select_recommender( meta_recommender: MetaRecommender, training_size: int ) -> PureRecommender: - """Select a recommender for given training dataset size.""" - searchspace = get_dummy_searchspace() - df_x, df_y = get_dummy_training_data(training_size) - return meta_recommender.select_recommender(searchspace, train_x=df_x, train_y=df_y) + """Select a recommender for a given training dataset size.""" + searchspace = Mock() + df = Mock() + df.__len__ = Mock(return_value=training_size) + return meta_recommender.select_recommender( + batch_size=1, searchspace=searchspace, measurements=df + ) diff --git a/tests/serialization/test_campaign_serialization.py b/tests/serialization/test_campaign_serialization.py index 105bb37ee..c8dafae13 100644 --- a/tests/serialization/test_campaign_serialization.py +++ b/tests/serialization/test_campaign_serialization.py @@ -15,7 +15,7 @@ def test_campaign_serialization(campaign): campaign2 = roundtrip(campaign) assert campaign == campaign2 - campaign.recommend() + campaign.recommend(batch_size=1) campaign2 = roundtrip(campaign) assert campaign == campaign2 diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index f2595b534..04cf5ff98 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -115,7 +115,7 @@ def test_deprecated_campaign_tolerance_flag(flag): def test_deprecated_batch_quantity_keyword(campaign): """Using the deprecated batch_quantity keyword raises an error.""" with pytest.raises(DeprecationError): - campaign.recommend(batch_quantity=5) + campaign.recommend(batch_size=5, batch_quantity=5) @pytest.mark.parametrize("flag", (True, False)) diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 000000000..a0d1f2e96 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,55 @@ +"""Integration tests.""" + +import numpy as np +import pandas as pd +import pytest +from pytest import param + +from baybe.parameters.numerical import NumericalDiscreteParameter +from baybe.recommenders.pure.nonpredictive.base import NonPredictiveRecommender +from baybe.searchspace.core import SearchSpace +from baybe.targets.numerical import NumericalTarget +from baybe.utils.basic import get_subclasses + +nonpredictive_recommenders = [ + param(cls(allow_recommending_already_measured=True), id=cls.__name__) + for cls in get_subclasses(NonPredictiveRecommender) +] + +p1 = NumericalDiscreteParameter("p1", [1, 2]) +t1 = NumericalTarget("t1", "MAX") +objective = t1.to_objective() +measurements = pd.DataFrame( + {p1.name: p1.values, t1.name: np.random.random(len(p1.values))} +) + + +@pytest.fixture(name="searchspace") +def fixture_searchspace(): + return SearchSpace.from_product([p1]) + + +@pytest.mark.parametrize("recommender", nonpredictive_recommenders) +def test_nonbayesian_recommender_with_measurements(recommender, searchspace): + """Calling a non-Bayesian recommender with training data raises a warning.""" + with pytest.warns( + UserWarning, + match=( + f"'{recommender.__class__.__name__}' does not utilize any training data" + ), + ): + recommender.recommend( + batch_size=1, searchspace=searchspace, measurements=measurements + ) + + +@pytest.mark.parametrize("recommender", nonpredictive_recommenders) +def test_nonbayesian_recommender_with_objective(recommender, searchspace): + """Calling a non-Bayesian recommender with an objective raises a warning.""" + with pytest.warns( + UserWarning, + match=(f"'{recommender.__class__.__name__}' does not consider any objectives"), + ): + recommender.recommend( + batch_size=1, searchspace=searchspace, objective=objective + )