From 0de44da1cb5b8ed012591cb265a53a12864e6749 Mon Sep 17 00:00:00 2001 From: StefanPSchmid Date: Mon, 15 Jun 2026 15:33:32 +0200 Subject: [PATCH 01/11] First draft Continuous optimizer --- baybe/recommenders/pure/bayesian/base.py | 22 +++- .../pure/bayesian/botorch/continuous.py | 37 +----- .../pure/bayesian/botorch/core.py | 8 ++ .../bayesian/botorch/optimizers/__init__.py | 7 ++ .../pure/bayesian/botorch/optimizers/base.py | 40 +++++++ .../pure/bayesian/botorch/optimizers/basic.py | 111 ++++++++++++++++++ 6 files changed, 193 insertions(+), 32 deletions(-) create mode 100644 baybe/recommenders/pure/bayesian/botorch/optimizers/__init__.py create mode 100644 baybe/recommenders/pure/bayesian/botorch/optimizers/base.py create mode 100644 baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py diff --git a/baybe/recommenders/pure/bayesian/base.py b/baybe/recommenders/pure/bayesian/base.py index db163a8556..55e652dc85 100644 --- a/baybe/recommenders/pure/bayesian/base.py +++ b/baybe/recommenders/pure/bayesian/base.py @@ -3,11 +3,12 @@ from __future__ import annotations import gc +import warnings from abc import ABC from typing import TYPE_CHECKING import pandas as pd -from attrs import define, field +from attrs import define, field, fields from attrs.converters import optional from typing_extensions import override @@ -19,6 +20,7 @@ ) from baybe.objectives.base import Objective from baybe.recommenders.pure.base import PureRecommender +from baybe.recommenders.pure.bayesian.botorch.optimizers.base import OptimizerProtocol from baybe.searchspace import SearchSpace from baybe.settings import Settings from baybe.surrogates import GaussianProcessSurrogate @@ -55,6 +57,12 @@ class BayesianRecommender(PureRecommender, ABC): ) """The acquisition function. When omitted, a default is used.""" + optimizer: OptimizerProtocol | None = field( + alias="optimizer", + default=None, + ) + """The acquisition function optimizer.""" + # TODO: The objective is currently only required for validating the recommendation # context. Once multi-target support is complete, we might want to refactor # the validation mechanism, e.g. by @@ -67,6 +75,18 @@ class BayesianRecommender(PureRecommender, ABC): _botorch_acqf = field(default=None, init=False, eq=False) """The induced BoTorch acquisition function.""" + @property + def surrogate_model(self) -> SurrogateProtocol: + """Deprecated!""" + warnings.warn( + f"Accessing the surrogate model via 'surrogate_model' has been " + f"deprecated. Use '{self.get_surrogate.__name__}' instead to get the " + f"trained model instance (or " + f"'{fields(type(self))._surrogate_model.name}' to access the raw object).", + DeprecationWarning, + ) + return self._surrogate_model + def _get_acquisition_function(self, objective: Objective) -> AcquisitionFunction: """Select the appropriate default acquisition function for the given context.""" if self.acquisition_function is None: diff --git a/baybe/recommenders/pure/bayesian/botorch/continuous.py b/baybe/recommenders/pure/bayesian/botorch/continuous.py index 4e43a247fb..0fc7a9f7f3 100644 --- a/baybe/recommenders/pure/bayesian/botorch/continuous.py +++ b/baybe/recommenders/pure/bayesian/botorch/continuous.py @@ -16,7 +16,7 @@ ) from baybe.parameters.numerical import _FixedNumericalContinuousParameter from baybe.searchspace import SubspaceContinuous -from baybe.utils.basic import flatten +from baybe.searchspace.core import SearchSpace if TYPE_CHECKING: from torch import Tensor @@ -147,9 +147,6 @@ def recommend_continuous_without_cardinality_constraints( Raises: ValueError: If the continuous search space has cardinality constraints. """ - import torch - from botorch.optim import optimize_acqf - if subspace_continuous.n_subsets > 0: raise ValueError( f"'{recommend_continuous_without_cardinality_constraints.__name__}' " @@ -181,32 +178,10 @@ def recommend_continuous_without_cardinality_constraints( # because it is unclear if the corresponding presence checks for these # arguments is correctly implemented in all invoked BoTorch subroutines. # For details: https://github.com/pytorch/botorch/issues/2042 - points, acqf_values = optimize_acqf( - acq_function=recommender._botorch_acqf, - bounds=torch.from_numpy( - subspace_continuous.comp_rep_bounds.to_numpy(copy=True) - ), - q=batch_size, - num_restarts=recommender.n_restarts, - raw_samples=recommender.n_raw_samples, - fixed_features=fixed_parameters or None, - equality_constraints=flatten( - c.to_botorch( - subspace_continuous.parameters, - batch_size=batch_size if c.is_interpoint else None, - ) - for c in subspace_continuous.constraints_lin_eq - ) - or None, - inequality_constraints=flatten( - c.to_botorch( - subspace_continuous.parameters, - batch_size=batch_size if c.is_interpoint else None, - ) - for c in subspace_continuous.constraints_lin_ineq - ) - or None, - sequential=recommender.sequential_continuous, + points, acqf_values = recommender.optimizer( + batch_size=batch_size, + acquisition_function=recommender._botorch_acqf, + searchspace=SearchSpace(continuous=subspace_continuous), + fixed_parameters=fixed_parameters, ) - assert acqf_values is not None # for mypy; guaranteed by optimize_acqf defaults return points, acqf_values diff --git a/baybe/recommenders/pure/bayesian/botorch/core.py b/baybe/recommenders/pure/bayesian/botorch/core.py index 7953d5ca74..d948f661c4 100644 --- a/baybe/recommenders/pure/bayesian/botorch/core.py +++ b/baybe/recommenders/pure/bayesian/botorch/core.py @@ -30,6 +30,7 @@ recommend_hybrid_with_subsets, recommend_hybrid_without_subsets, ) +from baybe.recommenders.pure.bayesian.botorch.optimizers.basic import GradientOptimizer from baybe.searchspace import ( SearchSpace, SearchSpaceType, @@ -213,6 +214,13 @@ def _recommend_continuous( f"acquisition functions for batch sizes > 1." ) + if self.optimizer is None: + self.optimizer = GradientOptimizer( + sequential_continuous=self.sequential_continuous, + n_restarts=self.n_restarts, + n_raw_samples=self.n_raw_samples, + ) + points, _ = recommend_continuous_torch(self, subspace_continuous, batch_size) return pd.DataFrame(points, columns=subspace_continuous.parameter_names) diff --git a/baybe/recommenders/pure/bayesian/botorch/optimizers/__init__.py b/baybe/recommenders/pure/bayesian/botorch/optimizers/__init__.py new file mode 100644 index 0000000000..eaf5773900 --- /dev/null +++ b/baybe/recommenders/pure/bayesian/botorch/optimizers/__init__.py @@ -0,0 +1,7 @@ +"""Acquisition function optimizers.""" + +from baybe.recommenders.pure.bayesian.botorch.optimizers.basic import GradientOptimizer + +__all__ = [ + "GradientOptimizer", +] diff --git a/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py b/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py new file mode 100644 index 0000000000..1b64640f68 --- /dev/null +++ b/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py @@ -0,0 +1,40 @@ +"""Base protocol for all optimizers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +from baybe.searchspace import SearchSpace + +if TYPE_CHECKING: + from botorch.acquisition import AcquisitionFunction as BoAcquisitionFunction + from torch import Tensor + + +@runtime_checkable +class OptimizerProtocol(Protocol): + """Type protocol specifying the interface optimizers need to implement.""" + + # Use slots so that derived classes also remain slotted + # See also: https://www.attrs.org/en/stable/glossary.html#term-slotted-classes + __slots__ = () + + def __call__( + self, + batch_size: int, + acquisition_function: BoAcquisitionFunction, + searchspace: SearchSpace, + fixed_parameters: dict[int, float] | None = None, + ) -> tuple[Tensor, Tensor]: + """Recommend a batch of points from the given search space. + + Args: + batch_size: The size of the recommendation batch. + acquisition_function: The acquisition function to be optimized. + searchspace: The search space from which to generate recommendations. + fixed_parameters: A dictionary mapping parameter indices to fixed values. + + Returns: + The recommendations and corresponding acquisition values. + """ + ... diff --git a/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py b/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py new file mode 100644 index 0000000000..6c84ef7507 --- /dev/null +++ b/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py @@ -0,0 +1,111 @@ +"""Low-level optimizers of acquisition functions.""" + +from __future__ import annotations + +import gc +from typing import TYPE_CHECKING +from typing_extensions import override + +from attrs import define, field +from attrs.validators import gt, instance_of + +from baybe.recommenders.pure.bayesian.botorch.optimizers.base import OptimizerProtocol +from baybe.searchspace import SearchSpace +from baybe.utils.basic import flatten + +if TYPE_CHECKING: + from botorch.acquisition import AcquisitionFunction as BoAcquisitionFunction + from torch import Tensor + + +@define(kw_only=True) +class GradientOptimizer(OptimizerProtocol): + """Acquisition function optimizer using gradient-based optimization.""" + + n_restarts: int = field(validator=[instance_of(int), gt(0)], default=10) + """Number of times gradient-based optimization is restarted from different initial + points. **Does not affect purely discrete optimization**. + """ + + n_raw_samples: int = field(validator=[instance_of(int), gt(0)], default=64) + """Number of raw samples drawn for the initialization heuristic in gradient-based + optimization. **Does not affect purely discrete optimization**. + """ + + sequential_continuous: bool = field(default=True) + """Flag defining whether to apply sequential greedy or batch optimization in + **continuous** search spaces. In discrete/hybrid spaces, sequential greedy + optimization is applied automatically. + """ + + @override + def __call__( + self, + batch_size: int, + acquisition_function: BoAcquisitionFunction, + searchspace: SearchSpace, + fixed_parameters: dict[int, float] | None = None, + ) -> tuple[Tensor, Tensor]: + """Recommend from a search space using gradient-based optimization. + + Args: + batch_size: The size of the recommendation batch. + acquisition_function: The acquisition function to be optimized. + searchspace: The search space from which to generate recommendations. + fixed_parameters: A dictionary mapping parameter indices to fixed values. + + Returns: + The recommendations and corresponding acquisition values. + + Raises: + NotImplementedError: If the search space has a discrete component. + ValueError: If the search space has cardinality constraints. + """ + import torch + from botorch.optim import optimize_acqf + + if not searchspace.discrete.is_empty: + raise NotImplementedError( + "Gradient-based optimization is not implemented " + "for non-empty discrete search spaces." + ) + + if searchspace.continuous.n_subsets > 0: + raise ValueError( + f"'{self.__class__.__name__}' " + f"expects a continuous subspace without cardinality constraints." + ) + + points, acqf_values = optimize_acqf( + acq_function=acquisition_function, + bounds=torch.from_numpy( + searchspace.continuous.comp_rep_bounds.to_numpy(copy=True) + ), + q=batch_size, + num_restarts=self.n_restarts, + raw_samples=self.n_raw_samples, + fixed_features=fixed_parameters or None, + equality_constraints=flatten( + c.to_botorch( + searchspace.continuous.parameters, + batch_size=batch_size if c.is_interpoint else None, + ) + for c in searchspace.continuous.constraints_lin_eq + ) + or None, + inequality_constraints=flatten( + c.to_botorch( + searchspace.continuous.parameters, + batch_size=batch_size if c.is_interpoint else None, + ) + for c in searchspace.continuous.constraints_lin_ineq + ) + or None, + sequential=self.sequential_continuous, + ) + + return points, acqf_values + + +# Collect leftover original slotted classes processed by `attrs.define` +gc.collect() From 6cbdd39c5c36be200df29550754a95933fa9292b Mon Sep 17 00:00:00 2001 From: Stefan Schmid <115277029+StefanPSchmid@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:55:47 +0200 Subject: [PATCH 02/11] Remove deprecated surrogate_model property that was there in 0.14 Removed deprecated surrogate_model property and its warning. --- baybe/recommenders/pure/bayesian/base.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/baybe/recommenders/pure/bayesian/base.py b/baybe/recommenders/pure/bayesian/base.py index 55e652dc85..96789a8cc8 100644 --- a/baybe/recommenders/pure/bayesian/base.py +++ b/baybe/recommenders/pure/bayesian/base.py @@ -75,18 +75,6 @@ class BayesianRecommender(PureRecommender, ABC): _botorch_acqf = field(default=None, init=False, eq=False) """The induced BoTorch acquisition function.""" - @property - def surrogate_model(self) -> SurrogateProtocol: - """Deprecated!""" - warnings.warn( - f"Accessing the surrogate model via 'surrogate_model' has been " - f"deprecated. Use '{self.get_surrogate.__name__}' instead to get the " - f"trained model instance (or " - f"'{fields(type(self))._surrogate_model.name}' to access the raw object).", - DeprecationWarning, - ) - return self._surrogate_model - def _get_acquisition_function(self, objective: Objective) -> AcquisitionFunction: """Select the appropriate default acquisition function for the given context.""" if self.acquisition_function is None: From 5236c3b0a6fd827f111948eb30be5a7182c82997 Mon Sep 17 00:00:00 2001 From: StefanPSchmid Date: Mon, 22 Jun 2026 18:30:44 +0200 Subject: [PATCH 03/11] OptimizerProtocol SearchSpaceType --- .../pure/bayesian/botorch/optimizers/base.py | 10 ++++++++-- .../pure/bayesian/botorch/optimizers/basic.py | 14 +++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py b/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py index 1b64640f68..7160a47a61 100644 --- a/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py +++ b/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py @@ -2,13 +2,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Protocol, runtime_checkable, TypeAlias, ClassVar +from collections.abc import Callable from baybe.searchspace import SearchSpace +from baybe.searchspace.core import SearchSpaceType if TYPE_CHECKING: from botorch.acquisition import AcquisitionFunction as BoAcquisitionFunction from torch import Tensor + Optimand: TypeAlias = Callable[[Tensor], Tensor] @runtime_checkable @@ -19,10 +22,13 @@ class OptimizerProtocol(Protocol): # See also: https://www.attrs.org/en/stable/glossary.html#term-slotted-classes __slots__ = () + compatibility: ClassVar[SearchSpaceType] + """Class variable reflecting the search space compatibility.""" + def __call__( self, batch_size: int, - acquisition_function: BoAcquisitionFunction, + acquisition_function: Optimand, searchspace: SearchSpace, fixed_parameters: dict[int, float] | None = None, ) -> tuple[Tensor, Tensor]: diff --git a/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py b/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py index 6c84ef7507..ec290e3820 100644 --- a/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py +++ b/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py @@ -3,14 +3,16 @@ from __future__ import annotations import gc -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from typing_extensions import override from attrs import define, field from attrs.validators import gt, instance_of +from baybe.exceptions import IncompatibleSearchSpaceError from baybe.recommenders.pure.bayesian.botorch.optimizers.base import OptimizerProtocol from baybe.searchspace import SearchSpace +from baybe.searchspace.core import SearchSpaceType from baybe.utils.basic import flatten if TYPE_CHECKING: @@ -22,6 +24,10 @@ class GradientOptimizer(OptimizerProtocol): """Acquisition function optimizer using gradient-based optimization.""" + # Class variables + compatibility: ClassVar[SearchSpaceType] = SearchSpaceType.CONTINUOUS + # See base class. + n_restarts: int = field(validator=[instance_of(int), gt(0)], default=10) """Number of times gradient-based optimization is restarted from different initial points. **Does not affect purely discrete optimization**. @@ -64,6 +70,12 @@ def __call__( import torch from botorch.optim import optimize_acqf + if searchspace.type is not self.compatibility: + raise IncompatibleSearchSpaceError( + f"'{self.__class__.__name__}' currently only supports " + f"continuous search spaces." + ) + if not searchspace.discrete.is_empty: raise NotImplementedError( "Gradient-based optimization is not implemented " From 0eed2f964922acee979e5d003f2ae20b9c42befa Mon Sep 17 00:00:00 2001 From: StefanPSchmid Date: Wed, 24 Jun 2026 17:53:31 +0200 Subject: [PATCH 04/11] Copy optimization logic files on BayesianRecommender logic, prepare delete Botorch recommender logic --- .../recommenders/pure/bayesian/continuous.py | 168 ++++++++++++ baybe/recommenders/pure/bayesian/discrete.py | 142 ++++++++++ baybe/recommenders/pure/bayesian/hybrid.py | 252 ++++++++++++++++++ 3 files changed, 562 insertions(+) create mode 100644 baybe/recommenders/pure/bayesian/continuous.py create mode 100644 baybe/recommenders/pure/bayesian/discrete.py create mode 100644 baybe/recommenders/pure/bayesian/hybrid.py diff --git a/baybe/recommenders/pure/bayesian/continuous.py b/baybe/recommenders/pure/bayesian/continuous.py new file mode 100644 index 0000000000..4e424ed330 --- /dev/null +++ b/baybe/recommenders/pure/bayesian/continuous.py @@ -0,0 +1,168 @@ +"""Continuous recommendation routines for BotorchRecommender.""" + +from __future__ import annotations + +import warnings +from collections.abc import Callable, Collection, Iterable +from typing import TYPE_CHECKING + +import pandas as pd + +from baybe.constraints.utils import is_cardinality_fulfilled +from baybe.exceptions import MinimumCardinalityViolatedWarning +from baybe.parameters.numerical import _FixedNumericalContinuousParameter +from baybe.searchspace import SubspaceContinuous +from baybe.searchspace.core import SearchSpace + +if TYPE_CHECKING: + from torch import Tensor + + from baybe.recommenders.pure.bayesian.base import BayesianRecommender + + +def recommend_continuous_torch( + recommender: BayesianRecommender, + subspace_continuous: SubspaceContinuous, + batch_size: int, +) -> tuple[Tensor, Tensor]: + """Dispatcher selecting the continuous optimization routine.""" + if subspace_continuous.n_subsets > 0: + return recommend_continuous_with_cardinality_constraints( + recommender, subspace_continuous, batch_size + ) + else: + return recommend_continuous_without_cardinality_constraints( + recommender, subspace_continuous, batch_size + ) + + +def recommend_continuous_with_cardinality_constraints( + recommender: BayesianRecommender, + subspace_continuous: SubspaceContinuous, + batch_size: int, +) -> tuple[Tensor, Tensor]: + """Recommend from a continuous space with cardinality constraints. + + Optimizes the acquisition function across subsets defined by cardinality + constraints and returns the best result. + + The specific collection of subsets considered by the recommender is obtained + as either the full combinatorial set of possible parameter splits or a random + selection thereof, depending on the upper bound specified by the corresponding + recommender attribute. + + In each subset, the constraint-imposed configuration is fixed, so that the + constraints can be removed and a regular optimization can be performed. The + recommendation is then constructed from the combined optimization results of the + unconstrained spaces. + + Args: + recommender: The recommender instance. + subspace_continuous: The continuous subspace from which to generate + recommendations. + batch_size: The size of the recommendation batch. + + Returns: + The recommendations and corresponding acquisition values. + + Raises: + ValueError: If the continuous search space has no cardinality + constraints. + """ + if subspace_continuous.n_subsets == 0: + raise ValueError( + f"'{recommend_continuous_with_cardinality_constraints.__name__}' " + f"expects a subspace with cardinality constraints." + ) + + # Determine search scope based on number of subset configurations + configs: Iterable[frozenset[str]] + if subspace_continuous.n_subsets <= recommender.max_n_subsets: + configs = subspace_continuous.inactive_parameter_combinations() + else: + configs = subspace_continuous._sample_inactive_parameters( + recommender.max_n_subsets + ) + + # Create closures for each subset configuration + def make_callable( + inactive_params: Collection[str], + ) -> Callable[[], tuple[Tensor, Tensor]]: + def optimize() -> tuple[Tensor, Tensor]: + import torch + + sub = subspace_continuous._enforce_cardinality_constraints(inactive_params) + # Note: We explicitly evaluate the acqf function for the batch + # because the object returned by the optimization routine may + # contain joint or individual acquisition values, depending on + # whether sequential or joint optimization is applied + p, _ = recommend_continuous_torch(recommender, sub, batch_size) + with torch.no_grad(): + acqf_value = recommender._botorch_acqf(p) + return p, acqf_value + + return optimize + + callables = (make_callable(ip) for ip in configs) + points, acqf_value = recommender._optimize_over_subsets(callables) + + # Check if any minimum cardinality constraints are violated + if not is_cardinality_fulfilled( + pd.DataFrame(points, columns=subspace_continuous.parameter_names), + subspace_continuous, + check_maximum=False, + ): + warnings.warn( + "At least one minimum cardinality constraint has been violated. " + "This may occur when parameter ranges extend beyond zero in both " + "directions, making the feasible region non-convex. For such " + "parameters, minimum cardinality constraints are currently not " + "enforced due to the complexity of the resulting optimization problem.", + MinimumCardinalityViolatedWarning, + ) + + return points, acqf_value + + +def recommend_continuous_without_cardinality_constraints( + recommender: BayesianRecommender, + subspace_continuous: SubspaceContinuous, + batch_size: int, +) -> tuple[Tensor, Tensor]: + """Recommend from a continuous search space without cardinality constraints. + + Args: + recommender: The recommender instance. + subspace_continuous: The continuous subspace from which to generate + recommendations. + batch_size: The size of the recommendation batch. + + Returns: + The recommendations and corresponding acquisition values. + + Raises: + ValueError: If the continuous search space has cardinality constraints. + """ + if subspace_continuous.n_subsets > 0: + raise ValueError( + f"'{recommend_continuous_without_cardinality_constraints.__name__}' " + f"expects a subspace without cardinality constraints." + ) + + fixed_parameters = { + idx: p.value + for (idx, p) in enumerate(subspace_continuous.parameters) + if isinstance(p, _FixedNumericalContinuousParameter) + } + + # NOTE: The explicit `or None` conversion is added as an additional safety net + # because it is unclear if the corresponding presence checks for these + # arguments is correctly implemented in all invoked BoTorch subroutines. + # For details: https://github.com/pytorch/botorch/issues/2042 + points, acqf_values = recommender.optimizer( + batch_size=batch_size, + acquisition_function=recommender._botorch_acqf, + searchspace=SearchSpace(continuous=subspace_continuous), + fixed_parameters=fixed_parameters, + ) + return points, acqf_values diff --git a/baybe/recommenders/pure/bayesian/discrete.py b/baybe/recommenders/pure/bayesian/discrete.py new file mode 100644 index 0000000000..9606657408 --- /dev/null +++ b/baybe/recommenders/pure/bayesian/discrete.py @@ -0,0 +1,142 @@ +"""Discrete recommendation routines for BotorchRecommender.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import TYPE_CHECKING + +import numpy as np +import numpy.typing as npt +import pandas as pd + +from baybe.searchspace import SubspaceDiscrete +from baybe.utils.dataframe import to_tensor + +if TYPE_CHECKING: + from torch import Tensor + + from baybe.recommenders.pure.bayesian.base import BayesianRecommender + + +def recommend_discrete_with_subsets( + recommender: BayesianRecommender, + subspace_discrete: SubspaceDiscrete, + candidates_exp: pd.DataFrame, + batch_size: int, +) -> pd.Index: + """Recommend from a discrete space with subset-generating constraints. + + Splits the candidate set into subsets according to subset-generating constraints, + runs optimization on each feasible subset, and returns the batch with + the highest joint acquisition value. Subsets with fewer candidates + than ``batch_size`` are skipped. + + Args: + recommender: The recommender instance. + subspace_discrete: The discrete subspace from which to generate + recommendations. + candidates_exp: The experimental representation of candidates. + batch_size: The size of the recommendation batch. + + Returns: + The dataframe indices of the recommended points. + """ + import torch + + masks: Iterable[npt.NDArray[np.bool_]] + if subspace_discrete.n_subsets <= recommender.max_n_subsets: + masks = subspace_discrete.subset_masks( + candidates_exp, min_candidates=batch_size + ) + else: + masks = subspace_discrete.sample_subset_masks( + candidates_exp, recommender.max_n_subsets, min_candidates=batch_size + ) + + def make_callable( + mask: np.ndarray, + ) -> Callable[[], tuple[pd.Index, Tensor]]: + def optimize() -> tuple[pd.Index, Tensor]: + subset = candidates_exp.loc[mask] + + idxs = recommend_discrete_without_subsets( + recommender, subspace_discrete, subset, batch_size + ) + + comp = subspace_discrete.transform(candidates_exp.loc[idxs]) + with torch.no_grad(): + acqf_value = recommender._botorch_acqf(to_tensor(comp).unsqueeze(0)) + return idxs, acqf_value + + return optimize + + callables = (make_callable(m) for m in masks) + best_idxs, _ = recommender._optimize_over_subsets(callables) + return best_idxs + + +def recommend_discrete_without_subsets( + recommender: BayesianRecommender, + subspace_discrete: SubspaceDiscrete, + candidates_exp: pd.DataFrame, + batch_size: int, +) -> pd.Index: + """Generate recommendations from a discrete search space. + + Args: + recommender: The recommender instance. + subspace_discrete: The discrete subspace from which to generate + recommendations. + candidates_exp: The experimental representation of all discrete candidate + points to be considered. + batch_size: The size of the recommendation batch. + + Raises: + IncompatibleAcquisitionFunctionError: If a non-Monte Carlo acquisition + function is used with a batch size > 1. + + Returns: + The dataframe indices of the recommended points in the provided + experimental representation. + """ + from baybe.acquisition.acqfs import qThompsonSampling + from baybe.exceptions import ( + IncompatibilityError, + IncompatibleAcquisitionFunctionError, + ) + + assert recommender._objective is not None + acqf = recommender._get_acquisition_function(recommender._objective) + if batch_size > 1 and not acqf.supports_batching: + raise IncompatibleAcquisitionFunctionError( + f"The '{recommender.__class__.__name__}' only works with Monte Carlo " + f"acquisition functions for batch sizes > 1." + ) + if batch_size > 1 and isinstance(acqf, qThompsonSampling): + raise IncompatibilityError( + "Thompson sampling currently only supports a batch size of 1." + ) + + from botorch.optim import optimize_acqf_discrete + + # determine the next set of points to be tested + candidates_comp = subspace_discrete.transform(candidates_exp) + points, _ = optimize_acqf_discrete( + recommender._botorch_acqf, batch_size, to_tensor(candidates_comp) + ) + + # retrieve the index of the points from the input dataframe + # IMPROVE: The merging procedure is conceptually similar to what + # `SearchSpace._match_measurement_with_searchspace_indices` does, though using + # a simpler matching logic. When refactoring the SearchSpace class to + # handle continuous parameters, a corresponding utility could be extracted. + idxs = pd.Index( + pd.merge( + pd.DataFrame(points, columns=candidates_comp.columns), + candidates_comp.reset_index(), + on=list(candidates_comp), + how="left", + )["index"] + ) + + return idxs diff --git a/baybe/recommenders/pure/bayesian/hybrid.py b/baybe/recommenders/pure/bayesian/hybrid.py new file mode 100644 index 0000000000..e04efaaa70 --- /dev/null +++ b/baybe/recommenders/pure/bayesian/hybrid.py @@ -0,0 +1,252 @@ +"""Hybrid recommendation routines for BotorchRecommender.""" + +from __future__ import annotations + +import math +import warnings +from collections.abc import Callable, Iterable +from typing import TYPE_CHECKING + +import numpy as np +import pandas as pd + +from baybe.constraints.utils import is_cardinality_fulfilled +from baybe.exceptions import ( + IncompatibilityError, + IncompatibleAcquisitionFunctionError, + MinimumCardinalityViolatedWarning, +) +from baybe.searchspace import SearchSpace +from baybe.utils.basic import flatten +from baybe.utils.dataframe import to_tensor +from baybe.utils.sampling_algorithms import sample_numerical_df + +if TYPE_CHECKING: + from torch import Tensor + + from baybe.recommenders.pure.bayesian.base import BayesianRecommender + + +def recommend_hybrid_without_subsets( + recommender: BayesianRecommender, + searchspace: SearchSpace, + candidates_exp: pd.DataFrame, + batch_size: int, +) -> pd.DataFrame: + """Recommend points using the ``optimize_acqf_mixed`` function of BoTorch. + + This functions samples points from the discrete subspace, performs optimization + in the continuous subspace with these points being fixed and returns the best + found solution. + + **Important**: This performs a brute-force calculation by fixing every possible + assignment of discrete variables and optimizing the continuous subspace for + each of them. It is thus computationally expensive. + + **Note**: This function implicitly assumes that discrete search space parts in + the respective data frame come first and continuous parts come second. + + Args: + recommender: The recommender instance. + searchspace: The search space in which the recommendations should be made. + candidates_exp: The experimental representation of the candidates + of the discrete subspace. + batch_size: The size of the calculated batch. + + Raises: + IncompatibleAcquisitionFunctionError: If a non-Monte Carlo acquisition + function is used with a batch size > 1. + + Returns: + The recommended points. + """ + assert recommender._objective is not None + + # Interpoint constraints cannot be used with optimize_acqf_mixed, see + # https://github.com/meta-pytorch/botorch/issues/2996 + if searchspace.continuous.has_interpoint_constraints: + raise IncompatibilityError( + "Interpoint constraints are not available in hybrid spaces." + ) + if ( + batch_size > 1 + and not recommender._get_acquisition_function( + recommender._objective + ).supports_batching + ): + raise IncompatibleAcquisitionFunctionError( + f"The '{recommender.__class__.__name__}' only works with Monte Carlo " + f"acquisition functions for batch sizes > 1." + ) + + import torch + from botorch.optim import optimize_acqf_mixed + + # Transform discrete candidates + candidates_comp = searchspace.discrete.transform(candidates_exp) + + # Calculate the number of samples from the given percentage + n_candidates = math.ceil( + recommender.sampling_percentage * len(candidates_comp.index) + ) + + # Potential sampling of discrete candidates + if recommender.hybrid_sampler is not None: + candidates_comp = sample_numerical_df( + candidates_comp, n_candidates, method=recommender.hybrid_sampler + ) + + # Prepare all considered discrete configurations in the + # List[Dict[int, float]] format expected by BoTorch. + num_comp_columns = len(candidates_comp.columns) + candidates_comp.columns = list(range(num_comp_columns)) + fixed_features_list = candidates_comp.to_dict("records") + + # Actual call of the BoTorch optimization routine + # NOTE: The explicit `or None` conversion is added as an additional safety net + # because it is unclear if the corresponding presence checks for these + # arguments is correctly implemented in all invoked BoTorch subroutines. + # For details: https://github.com/pytorch/botorch/issues/2042 + points, _ = optimize_acqf_mixed( + acq_function=recommender._botorch_acqf, + bounds=torch.from_numpy(searchspace.comp_rep_bounds.to_numpy(copy=True)), + q=batch_size, + num_restarts=recommender.n_restarts, + raw_samples=recommender.n_raw_samples, + fixed_features_list=fixed_features_list, # type: ignore[arg-type] + equality_constraints=flatten( + c.to_botorch( + searchspace.continuous.parameters, + idx_offset=len(candidates_comp.columns), + batch_size=batch_size if c.is_interpoint else None, + ) + for c in searchspace.continuous.constraints_lin_eq + ) + or None, + inequality_constraints=flatten( + c.to_botorch( + searchspace.continuous.parameters, + idx_offset=num_comp_columns, + batch_size=batch_size if c.is_interpoint else None, + ) + for c in searchspace.continuous.constraints_lin_ineq + ) + or None, + ) + + # Align candidates with search space index. Done via including the search space + # index during the merge, which is used later for back-translation into the + # experimental representation + merged = pd.merge( + pd.DataFrame(points), + candidates_comp.reset_index(), + on=list(candidates_comp.columns), + how="left", + ).set_index("index") + + # Get experimental representation of discrete part + rec_disc_exp = searchspace.discrete.exp_rep.loc[merged.index] + + # Combine discrete and continuous parts + rec_exp = pd.concat( + [ + rec_disc_exp, + merged.iloc[:, num_comp_columns:].set_axis( + searchspace.continuous.parameter_names, axis=1 + ), + ], + axis=1, + ) + + return rec_exp + + +def recommend_hybrid_with_subsets( + recommender: BayesianRecommender, + searchspace: SearchSpace, + candidates_exp: pd.DataFrame, + batch_size: int, +) -> pd.DataFrame: + """Recommend from a hybrid space with subset constraints. + + Uses ``SearchSpace.subsets()`` to enumerate the Cartesian + product of discrete and continuous subset configurations, capped at + ``max_n_subsets`` total. In purely discrete search spaces, subsets + with fewer candidates than ``batch_size`` are pre-filtered. + + Args: + recommender: The recommender instance. + searchspace: The search space in which the recommendations should be made. + candidates_exp: The experimental representation of the candidates + of the discrete subspace. + batch_size: The size of the calculated batch. + + Returns: + The recommended points. + """ + from attrs import evolve + + subspace_c = searchspace.continuous + + # Get combined configurations, capped at max_n_subsets + # NOTE: No min_discrete_candidates filtering in hybrid spaces because + # optimize_acqf_mixed can produce multiple recommendations from a single + # discrete candidate by varying continuous parameters. + combined_masks: Iterable[tuple[np.ndarray, frozenset[str]]] + if searchspace.n_subsets <= recommender.max_n_subsets: + combined_masks = searchspace.subsets(candidates_exp) + else: + combined_masks = searchspace.sample_subsets( + candidates_exp, recommender.max_n_subsets + ) + + def make_callable( + d_mask: np.ndarray, + c_inactive_params: frozenset[str], + ) -> Callable[[], tuple[pd.DataFrame, Tensor]]: + def optimize() -> tuple[pd.DataFrame, Tensor]: + import torch + + subset = candidates_exp.loc[d_mask] + + if c_inactive_params: + mod_cont = subspace_c._enforce_cardinality_constraints( + c_inactive_params + ) + else: + mod_cont = subspace_c + mod_searchspace = evolve(searchspace, continuous=mod_cont) + + rec = recommend_hybrid_without_subsets( + recommender, mod_searchspace, subset, batch_size + ) + + comp = mod_searchspace.transform(rec) + with torch.no_grad(): + acqf_value = recommender._botorch_acqf( + to_tensor(comp.values).unsqueeze(0) + ) + return rec, acqf_value + + return optimize + + callables = (make_callable(d_mask, c_ip) for d_mask, c_ip in combined_masks) + best_rec, _ = recommender._optimize_over_subsets(callables) + + # Post-check minimum cardinality on continuous columns + if subspace_c.constraints_cardinality and not is_cardinality_fulfilled( + best_rec[list(subspace_c.parameter_names)], + subspace_c, + check_maximum=False, + ): + warnings.warn( + "At least one minimum cardinality constraint has been violated. " + "This may occur when parameter ranges extend beyond zero in both " + "directions, making the feasible region non-convex. For such " + "parameters, minimum cardinality constraints are currently not " + "enforced due to the complexity of the resulting optimization " + "problem.", + MinimumCardinalityViolatedWarning, + ) + + return best_rec From 355ce33a9a75451e83e596ecad959d68c9ba987e Mon Sep 17 00:00:00 2001 From: StefanPSchmid Date: Wed, 24 Jun 2026 17:55:05 +0200 Subject: [PATCH 05/11] Resolve mypy issues --- .../pure/bayesian/botorch/optimizers/base.py | 4 +++- .../pure/bayesian/botorch/optimizers/basic.py | 22 ++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py b/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py index 7160a47a61..0a1cba3421 100644 --- a/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py +++ b/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py @@ -8,10 +8,12 @@ from baybe.searchspace import SearchSpace from baybe.searchspace.core import SearchSpaceType +Optimand: TypeAlias = Callable[[Tensor], Tensor] +"Type alias for the callable to be optimized." + if TYPE_CHECKING: from botorch.acquisition import AcquisitionFunction as BoAcquisitionFunction from torch import Tensor - Optimand: TypeAlias = Callable[[Tensor], Tensor] @runtime_checkable diff --git a/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py b/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py index ec290e3820..0156467d70 100644 --- a/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py +++ b/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py @@ -3,12 +3,13 @@ from __future__ import annotations import gc -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, ClassVar, cast from typing_extensions import override -from attrs import define, field +from attrs import define, field, fields from attrs.validators import gt, instance_of +from baybe.exceptions import IncompatibilityError from baybe.exceptions import IncompatibleSearchSpaceError from baybe.recommenders.pure.bayesian.botorch.optimizers.base import OptimizerProtocol from baybe.searchspace import SearchSpace @@ -16,8 +17,8 @@ from baybe.utils.basic import flatten if TYPE_CHECKING: - from botorch.acquisition import AcquisitionFunction as BoAcquisitionFunction from torch import Tensor + from baybe.recommenders.pure.bayesian.botorch.optimizers.base import Optimand @define(kw_only=True) @@ -48,7 +49,7 @@ class GradientOptimizer(OptimizerProtocol): def __call__( self, batch_size: int, - acquisition_function: BoAcquisitionFunction, + acquisition_function: Optimand, searchspace: SearchSpace, fixed_parameters: dict[int, float] | None = None, ) -> tuple[Tensor, Tensor]: @@ -69,6 +70,7 @@ def __call__( """ import torch from botorch.optim import optimize_acqf + from botorch.acquisition import AcquisitionFunction as BoAcquisitionFunction if searchspace.type is not self.compatibility: raise IncompatibleSearchSpaceError( @@ -76,6 +78,16 @@ def __call__( f"continuous search spaces." ) + # TODO: Add option for automatic choice once the "settings" PR is merged, + # which ships the necessary machinery + if self.sequential_continuous and searchspace.continuous.has_interpoint_constraints: + raise IncompatibilityError( + f"Setting the " + f"'{fields(self.__class__).sequential_continuous.name}' " + f"flag to ``True`` while interpoint constraints are present in the " + f"continuous subspace is not supported. " + ) + if not searchspace.discrete.is_empty: raise NotImplementedError( "Gradient-based optimization is not implemented " @@ -89,7 +101,7 @@ def __call__( ) points, acqf_values = optimize_acqf( - acq_function=acquisition_function, + acq_function=cast(BoAcquisitionFunction, acquisition_function), bounds=torch.from_numpy( searchspace.continuous.comp_rep_bounds.to_numpy(copy=True) ), From 9cb1d301e26572dd3dc6b890dbb8d9e52d23891d Mon Sep 17 00:00:00 2001 From: StefanPSchmid Date: Wed, 24 Jun 2026 17:55:59 +0200 Subject: [PATCH 06/11] Resolve mypy issues, prepare deletion of BotorchRec --- baybe/recommenders/pure/bayesian/base.py | 198 +++++++++++++++++- .../pure/bayesian/botorch/core.py | 8 +- 2 files changed, 195 insertions(+), 11 deletions(-) diff --git a/baybe/recommenders/pure/bayesian/base.py b/baybe/recommenders/pure/bayesian/base.py index 96789a8cc8..b3fef1272e 100644 --- a/baybe/recommenders/pure/bayesian/base.py +++ b/baybe/recommenders/pure/bayesian/base.py @@ -5,11 +5,14 @@ import gc import warnings from abc import ABC -from typing import TYPE_CHECKING +from collections.abc import Callable, Iterable +from typing import TYPE_CHECKING, Any +import numpy as np import pandas as pd from attrs import define, field, fields from attrs.converters import optional +from attrs.validators import ge, gt, instance_of from typing_extensions import override from baybe.acquisition import qLogEI, qLogNEHVI @@ -17,11 +20,28 @@ from baybe.acquisition.utils import convert_acqf from baybe.exceptions import ( IncompatibleAcquisitionFunctionError, + InfeasibilityError, ) from baybe.objectives.base import Objective from baybe.recommenders.pure.base import PureRecommender +from baybe.recommenders.pure.bayesian.continuous import ( + recommend_continuous_torch, +) +from baybe.recommenders.pure.bayesian.discrete import ( + recommend_discrete_with_subsets, + recommend_discrete_without_subsets, +) +from baybe.recommenders.pure.bayesian.hybrid import ( + recommend_hybrid_with_subsets, + recommend_hybrid_without_subsets, +) from baybe.recommenders.pure.bayesian.botorch.optimizers.base import OptimizerProtocol -from baybe.searchspace import SearchSpace +from baybe.recommenders.pure.bayesian.botorch.optimizers.basic import GradientOptimizer +from baybe.searchspace import ( + SearchSpace, + SubspaceContinuous, + SubspaceDiscrete, +) from baybe.settings import Settings from baybe.surrogates import GaussianProcessSurrogate from baybe.surrogates.base import ( @@ -29,9 +49,11 @@ SurrogateProtocol, ) from baybe.utils.validation import preprocess_dataframe, validate_object_names +from baybe.utils.sampling_algorithms import DiscreteSamplingMethod if TYPE_CHECKING: from botorch.acquisition import AcquisitionFunction as BoAcquisitionFunction + from torch import Tensor def _autoreplicate(surrogate: SurrogateProtocol, /) -> SurrogateProtocol: @@ -57,12 +79,39 @@ class BayesianRecommender(PureRecommender, ABC): ) """The acquisition function. When omitted, a default is used.""" - optimizer: OptimizerProtocol | None = field( + optimizer: OptimizerProtocol = field( alias="optimizer", - default=None, + default=GradientOptimizer(), ) """The acquisition function optimizer.""" + #TODO: Move fields to respective optimizers + hybrid_sampler: DiscreteSamplingMethod | None = field( + converter=optional(DiscreteSamplingMethod), default=None + ) + """Strategy used for sampling the discrete subspace when performing hybrid search + space optimization.""" + + sampling_percentage: float = field(default=1.0) + """Percentage of discrete search space that is sampled when performing hybrid search + space optimization. Ignored when ``hybrid_sampler="None"``.""" + + n_restarts: int = field(validator=[instance_of(int), gt(0)], default=10) + """Number of times gradient-based optimization is restarted from different initial + points. **Does not affect purely discrete optimization**. + """ + + n_raw_samples: int = field(validator=[instance_of(int), gt(0)], default=64) + """Number of raw samples drawn for the initialization heuristic in gradient-based + optimization. **Does not affect purely discrete optimization**. + """ + + max_n_subsets: int = field(default=10, validator=[instance_of(int), ge(1)]) + """Maximum number of subsets to evaluate when subset-generating constraints are + present (e.g., continuous cardinality constraints). If the total number of + subsets exceeds this limit, a random subset of that size is sampled for + optimization instead of performing an exhaustive search.""" + # TODO: The objective is currently only required for validating the recommendation # context. Once multi-target support is complete, we might want to refactor # the validation mechanism, e.g. by @@ -204,6 +253,147 @@ def recommend( else: raise + @override + def _recommend_discrete( + self, + subspace_discrete: SubspaceDiscrete, + candidates_exp: pd.DataFrame, + batch_size: int, + ) -> pd.Index: + """Generate recommendations from a discrete search space. + + Dispatches to the appropriate optimization routine depending on whether + subset constraints are present. + + Args: + subspace_discrete: The discrete subspace from which to generate + recommendations. + candidates_exp: The experimental representation of all discrete candidate + points to be considered. + batch_size: The size of the recommendation batch. + + Returns: + The dataframe indices of the recommended points in the provided + experimental representation. + """ + if subspace_discrete.n_subsets > 0: + return recommend_discrete_with_subsets( + self, subspace_discrete, candidates_exp, batch_size + ) + return recommend_discrete_without_subsets( + self, subspace_discrete, candidates_exp, batch_size + ) + + @override + def _recommend_continuous( + self, + subspace_continuous: SubspaceContinuous, + batch_size: int, + ) -> pd.DataFrame: + """Generate recommendations from a continuous search space. + + Args: + subspace_continuous: The continuous subspace from which to generate + recommendations. + batch_size: The size of the recommendation batch. + + Raises: + IncompatibleAcquisitionFunctionError: If a non-Monte Carlo acquisition + function is used with a batch size > 1. + + Returns: + A dataframe containing the recommendations as individual rows. + """ + assert self._objective is not None + if ( + batch_size > 1 + and not self._get_acquisition_function(self._objective).supports_batching + ): + raise IncompatibleAcquisitionFunctionError( + f"The '{self.__class__.__name__}' only works with Monte Carlo " + f"acquisition functions for batch sizes > 1." + ) + + points, _ = recommend_continuous_torch(self, subspace_continuous, batch_size) + + return pd.DataFrame(points, columns=subspace_continuous.parameter_names) + + @override + def _recommend_hybrid( + self, + searchspace: SearchSpace, + candidates_exp: pd.DataFrame, + batch_size: int, + ) -> pd.DataFrame: + """Generate recommendations from a hybrid search space. + + Dispatches to the appropriate optimization routine depending on whether + subset constraints are present. + + Args: + searchspace: The search space in which the recommendations should be made. + candidates_exp: The experimental representation of the candidates + of the discrete subspace. + batch_size: The size of the calculated batch. + + Returns: + The recommended points. + """ + if searchspace.n_subsets > 0: + return recommend_hybrid_with_subsets( + self, searchspace, candidates_exp, batch_size + ) + return recommend_hybrid_without_subsets( + self, searchspace, candidates_exp, batch_size + ) + + def _optimize_over_subsets( + self, + subset_callables: Iterable[Callable[[], tuple[Any, Tensor]]], + ) -> tuple[Any, Tensor]: + """Optimize across subsets and return the result with the best acqf value. + + Each callable performs optimization for one subset configuration and returns + a ``(result, acquisition_value)`` tuple. Subsets that raise + ``InfeasibilityError`` are silently skipped. + + Args: + subset_callables: An iterable of zero-argument callables. Each callable + runs the optimization for one subset and returns + ``(result, acqf_value)``. It may raise ``InfeasibilityError`` if the + subset is infeasible. + + Raises: + InfeasibilityError: If none of the subsets has a feasible solution. + + Returns: + The result and acquisition value of the best subset. + """ + from botorch.exceptions.errors import InfeasibilityError as BoInfeasibilityError + + results_all: list = [] + acqf_values_all: list[Tensor] = [] + + for optimize_fn in subset_callables: + try: + result, acqf_value = optimize_fn() + results_all.append(result) + acqf_values_all.append(acqf_value) + except (BoInfeasibilityError, InfeasibilityError): + pass + + if not results_all: + raise InfeasibilityError( + "No feasible solution could be found. Potentially the specified " + "constraints are too restrictive, i.e. there may be too many " + "constraints or thresholds may have been set too tightly. " + "Consider relaxing the constraints to improve the chances " + "of finding a feasible solution." + ) + + best_idx = np.argmax(acqf_values_all) + return results_all[best_idx], acqf_values_all[best_idx] + def acquisition_values( self, candidates: pd.DataFrame, diff --git a/baybe/recommenders/pure/bayesian/botorch/core.py b/baybe/recommenders/pure/bayesian/botorch/core.py index d948f661c4..736ba81554 100644 --- a/baybe/recommenders/pure/bayesian/botorch/core.py +++ b/baybe/recommenders/pure/bayesian/botorch/core.py @@ -214,13 +214,6 @@ def _recommend_continuous( f"acquisition functions for batch sizes > 1." ) - if self.optimizer is None: - self.optimizer = GradientOptimizer( - sequential_continuous=self.sequential_continuous, - n_restarts=self.n_restarts, - n_raw_samples=self.n_raw_samples, - ) - points, _ = recommend_continuous_torch(self, subspace_continuous, batch_size) return pd.DataFrame(points, columns=subspace_continuous.parameter_names) @@ -254,6 +247,7 @@ def _recommend_hybrid( self, searchspace, candidates_exp, batch_size ) + @override def _optimize_over_subsets( self, subset_callables: Iterable[Callable[[], tuple[Any, Tensor]]], From e3d29831909b3915cc5fb045e7899ad6e0f947a7 Mon Sep 17 00:00:00 2001 From: StefanPSchmid Date: Fri, 26 Jun 2026 19:24:56 +0200 Subject: [PATCH 07/11] Exchange BotorchRecommender with BayesianRecommender throughout repo --- AGENTS.md | 2 +- README.md | 4 ++-- examples/Basics/recommenders.py | 4 ++-- examples/Constraints_Continuous/interpoint.py | 4 ++-- examples/Custom_Hooks/campaign_stopping.py | 12 ++++++------ examples/Custom_Hooks/probability_of_improvement.py | 12 ++++++------ examples/Custom_Surrogates/custom_pretrained.py | 4 ++-- examples/Custom_Surrogates/surrogate_params.py | 4 ++-- .../bernoulli_multi_armed_bandit.py | 4 ++-- examples/Searchspaces/hybrid_space.py | 2 +- examples/Serialization/basic_serialization.py | 4 ++-- examples/Serialization/create_from_config.py | 2 +- examples/Serialization/validate_config.py | 4 ++-- streamlit/surrogate_models.py | 6 +++--- 14 files changed, 34 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 59629ab1fb..0696db1519 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ Custom `@classproperty` from `baybe.utils.basic` for class-level computed proper | Element | Convention | Examples | |---------|------------|---------| | Variables/functions | `snake_case` | `batch_size`, `add_measurements` | -| Classes | `PascalCase` | `Campaign`, `BotorchRecommender` | +| Classes | `PascalCase` | `Campaign`, `BayesianRecommender` | | Constants | `SCREAMING_SNAKE_CASE` | `_RECOMMENDED`, `_TYPE_FIELD` | | Private members | `_` prefix | `_cached_recommendation`, `_fit()` | | Booleans | `is_`/`has_`/`supports_` | `is_numerical`, `supports_transfer_learning` | diff --git a/README.md b/README.md index 6cfe839f20..064a72bc75 100644 --- a/README.md +++ b/README.md @@ -234,14 +234,14 @@ For our example, we combine two recommenders via a so-called meta recommender na ```python from baybe.recommenders import ( - BotorchRecommender, + BayesianRecommender, FPSRecommender, TwoPhaseMetaRecommender, ) recommender = TwoPhaseMetaRecommender( initial_recommender=FPSRecommender(), # farthest point sampling - recommender=BotorchRecommender(), # Bayesian model-based optimization + recommender=BayesianRecommender(), # Bayesian model-based optimization ) ``` diff --git a/examples/Basics/recommenders.py b/examples/Basics/recommenders.py index 14a87731d1..39309022e3 100644 --- a/examples/Basics/recommenders.py +++ b/examples/Basics/recommenders.py @@ -20,7 +20,7 @@ from baybe.objectives import SingleTargetObjective from baybe.parameters import NumericalDiscreteParameter, SubstanceParameter from baybe.recommenders import ( - BotorchRecommender, + BayesianRecommender, RandomRecommender, TwoPhaseMetaRecommender, ) @@ -96,7 +96,7 @@ recommender = TwoPhaseMetaRecommender( initial_recommender=INITIAL_RECOMMENDER, - recommender=BotorchRecommender( + recommender=BayesianRecommender( surrogate_model=SURROGATE_MODEL, acquisition_function=ACQ_FUNCTION ), ) diff --git a/examples/Constraints_Continuous/interpoint.py b/examples/Constraints_Continuous/interpoint.py index 0bfe1584f6..30f01a9d79 100644 --- a/examples/Constraints_Continuous/interpoint.py +++ b/examples/Constraints_Continuous/interpoint.py @@ -30,7 +30,7 @@ from baybe import Campaign, active_settings from baybe.constraints import ContinuousLinearConstraint from baybe.parameters import NumericalContinuousParameter -from baybe.recommenders import BotorchRecommender +from baybe.recommenders import BayesianRecommender from baybe.recommenders.meta.sequential import TwoPhaseMetaRecommender from baybe.searchspace import SearchSpace from baybe.targets import NumericalTarget @@ -122,7 +122,7 @@ ) objective = NumericalTarget(name="Reaction_Yield").to_objective() recommender = TwoPhaseMetaRecommender( - recommender=BotorchRecommender(sequential_continuous=False) + recommender=BayesianRecommender(sequential_continuous=False) ) campaign = Campaign( searchspace=searchspace, diff --git a/examples/Custom_Hooks/campaign_stopping.py b/examples/Custom_Hooks/campaign_stopping.py index 208cbe60f9..28339f5317 100644 --- a/examples/Custom_Hooks/campaign_stopping.py +++ b/examples/Custom_Hooks/campaign_stopping.py @@ -26,7 +26,7 @@ from baybe.objectives.base import Objective from baybe.parameters import NumericalDiscreteParameter, SubstanceParameter from baybe.recommenders import ( - BotorchRecommender, + BayesianRecommender, RandomRecommender, TwoPhaseMetaRecommender, ) @@ -87,7 +87,7 @@ objective = SingleTargetObjective(target=NumericalTarget(name="yield")) recommender = TwoPhaseMetaRecommender( - initial_recommender=RandomRecommender(), recommender=BotorchRecommender() + initial_recommender=RandomRecommender(), recommender=BayesianRecommender() ) ### Simulating the Uninterrupted Campaigns @@ -126,7 +126,7 @@ class CampaignStoppedException(Exception): def stop_on_PI( - self: BotorchRecommender, + self: BayesianRecommender, searchspace: SearchSpace, objective: Objective | None = None, measurements: pd.DataFrame | None = None, @@ -155,8 +155,8 @@ def stop_on_PI( # Now, we attach the hook to the ``recommend`` function of our recommender class: -BotorchRecommender.recommend = register_hooks( - BotorchRecommender.recommend, post_hooks=[stop_on_PI] +BayesianRecommender.recommend = register_hooks( + BayesianRecommender.recommend, post_hooks=[stop_on_PI] ) active_settings.parallelize_simulation_runs = False @@ -178,7 +178,7 @@ def stop_on_PI( # hook and assign it to a fresh copy of the campaign: recommender_with_hook = TwoPhaseMetaRecommender( - initial_recommender=RandomRecommender(), recommender=BotorchRecommender() + initial_recommender=RandomRecommender(), recommender=BayesianRecommender() ) campaign_with_hook = Campaign(searchspace, objective, recommender) diff --git a/examples/Custom_Hooks/probability_of_improvement.py b/examples/Custom_Hooks/probability_of_improvement.py index 70511fffeb..49b7534c37 100644 --- a/examples/Custom_Hooks/probability_of_improvement.py +++ b/examples/Custom_Hooks/probability_of_improvement.py @@ -4,7 +4,7 @@ # {func}`register_hooks ` utility can be used to # extract the *Probability of Improvement (PI)* from a running campaign: # * We define a hook that is compatible with the -# {meth}`BotorchRecommender.recommend ` +# {meth}`BayesianRecommender.recommend ` # interface and lets us extract the PI achieved after each experimental iteration, # * attach the hook to the recommender driving our campaign, # * and plot the evolving PI values after campaign completion. @@ -29,7 +29,7 @@ from baybe.objectives.base import Objective from baybe.parameters import NumericalDiscreteParameter from baybe.recommenders import ( - BotorchRecommender, + BayesianRecommender, RandomRecommender, TwoPhaseMetaRecommender, ) @@ -67,7 +67,7 @@ def extract_pi( - self: BotorchRecommender, + self: BayesianRecommender, searchspace: SearchSpace, objective: Objective | None = None, measurements: pd.DataFrame | None = None, @@ -91,12 +91,12 @@ def extract_pi( # Next, we create our recommender and monkeypatch its `recommend` method: -bayesian_recommender = BotorchRecommender( +bayesian_recommender = BayesianRecommender( surrogate_model=GaussianProcessSurrogate(), ) bayesian_recommender.recommend = MethodType( register_hooks( - BotorchRecommender.recommend, + BayesianRecommender.recommend, post_hooks=[extract_pi], ), bayesian_recommender, @@ -107,7 +107,7 @@ def extract_pi( ) # In this example, we use `MethodType` to bind the -# {meth}`BotorchRecommender.recommend ` +# {meth}`BayesianRecommender.recommend ` # **function** with our hook. # For more information, we refer to the [`basic example`](./basics.md) explaining the # hook mechanics. diff --git a/examples/Custom_Surrogates/custom_pretrained.py b/examples/Custom_Surrogates/custom_pretrained.py index 5e408fd4b4..b6dc3bf46a 100644 --- a/examples/Custom_Surrogates/custom_pretrained.py +++ b/examples/Custom_Surrogates/custom_pretrained.py @@ -19,7 +19,7 @@ from baybe.objectives import SingleTargetObjective from baybe.parameters import NumericalDiscreteParameter from baybe.recommenders import ( - BotorchRecommender, + BayesianRecommender, FPSRecommender, TwoPhaseMetaRecommender, ) @@ -102,7 +102,7 @@ searchspace=SearchSpace.from_product(parameters=parameters, constraints=None), objective=SingleTargetObjective(target=NumericalTarget(name="Yield")), recommender=TwoPhaseMetaRecommender( - recommender=BotorchRecommender(surrogate_model=surrogate_model), + recommender=BayesianRecommender(surrogate_model=surrogate_model), initial_recommender=FPSRecommender(), ), ) diff --git a/examples/Custom_Surrogates/surrogate_params.py b/examples/Custom_Surrogates/surrogate_params.py index d9d4852fbe..b890f24d4c 100644 --- a/examples/Custom_Surrogates/surrogate_params.py +++ b/examples/Custom_Surrogates/surrogate_params.py @@ -19,7 +19,7 @@ SubstanceParameter, ) from baybe.recommenders import ( - BotorchRecommender, + BayesianRecommender, FPSRecommender, TwoPhaseMetaRecommender, ) @@ -84,7 +84,7 @@ searchspace=SearchSpace.from_product(parameters=parameters, constraints=None), objective=SingleTargetObjective(target=NumericalTarget(name="Yield")), recommender=TwoPhaseMetaRecommender( - recommender=BotorchRecommender(surrogate_model=surrogate_model), + recommender=BayesianRecommender(surrogate_model=surrogate_model), initial_recommender=FPSRecommender(), ), ) diff --git a/examples/Multi_Armed_Bandit/bernoulli_multi_armed_bandit.py b/examples/Multi_Armed_Bandit/bernoulli_multi_armed_bandit.py index d8c80317cb..9728952b09 100644 --- a/examples/Multi_Armed_Bandit/bernoulli_multi_armed_bandit.py +++ b/examples/Multi_Armed_Bandit/bernoulli_multi_armed_bandit.py @@ -19,7 +19,7 @@ from baybe.acquisition.base import AcquisitionFunction from baybe.parameters import CategoricalParameter from baybe.recommenders import ( - BotorchRecommender, + BayesianRecommender, RandomRecommender, TwoPhaseMetaRecommender, ) @@ -124,7 +124,7 @@ def simulate(acqf: AcquisitionFunction) -> SimulationResult: """Simulate the campaign with with the given acquisition function.""" recommender = TwoPhaseMetaRecommender( initial_recommender=RandomRecommender(), - recommender=BotorchRecommender( + recommender=BayesianRecommender( surrogate_model=surrogate, acquisition_function=acqf ), ) diff --git a/examples/Searchspaces/hybrid_space.py b/examples/Searchspaces/hybrid_space.py index 7d2b82d832..3013891d91 100644 --- a/examples/Searchspaces/hybrid_space.py +++ b/examples/Searchspaces/hybrid_space.py @@ -114,7 +114,7 @@ # Here, we explicitly create a recommender object to use the `NaiveHybridSpaceRecommender`. # The keywords `disc_recommender` and `cont_recommender` can be used to select different # recommenders for the corresponding subspaces. -# We use the default choices, which is the `BotorchRecommender`. +# We use the default choices, which is the `BayesianRecommender`. hybrid_recommender = TwoPhaseMetaRecommender(recommender=NaiveHybridSpaceRecommender()) diff --git a/examples/Serialization/basic_serialization.py b/examples/Serialization/basic_serialization.py index 77f48dac79..001853f202 100644 --- a/examples/Serialization/basic_serialization.py +++ b/examples/Serialization/basic_serialization.py @@ -17,7 +17,7 @@ NumericalDiscreteParameter, ) from baybe.recommenders import ( - BotorchRecommender, + BayesianRecommender, FPSRecommender, TwoPhaseMetaRecommender, ) @@ -49,7 +49,7 @@ searchspace=SearchSpace.from_product(parameters=parameters, constraints=None), objective=SingleTargetObjective(target=NumericalTarget(name="Yield")), recommender=TwoPhaseMetaRecommender( - recommender=BotorchRecommender(), + recommender=BayesianRecommender(), initial_recommender=FPSRecommender(), ), ) diff --git a/examples/Serialization/create_from_config.py b/examples/Serialization/create_from_config.py index 4385599090..9ab67df71c 100644 --- a/examples/Serialization/create_from_config.py +++ b/examples/Serialization/create_from_config.py @@ -72,7 +72,7 @@ "type": "FPSRecommender" }, "recommender": { - "type": "BotorchRecommender", + "type": "BayesianRecommender", "surrogate_model": { "type": "GaussianProcessSurrogate" }, diff --git a/examples/Serialization/validate_config.py b/examples/Serialization/validate_config.py index 166266acf1..3377e28213 100644 --- a/examples/Serialization/validate_config.py +++ b/examples/Serialization/validate_config.py @@ -71,7 +71,7 @@ "type": "FPSRecommender" }, "recommender": { - "type": "BotorchRecommender", + "type": "BayesianRecommender", "surrogate_model": { "type": "GaussianProcessSurrogate" }, @@ -140,7 +140,7 @@ "type": "FPSRecommender" }, "recommender": { - "type": "BotorchRecommender", + "type": "BayesianRecommender", "surrogate_model": { "type": "GaussianProcessSurrogate" }, diff --git a/streamlit/surrogate_models.py b/streamlit/surrogate_models.py index 3630f1d2f8..f7e3ebcf31 100644 --- a/streamlit/surrogate_models.py +++ b/streamlit/surrogate_models.py @@ -20,7 +20,7 @@ from baybe.acquisition.base import AcquisitionFunction from baybe.exceptions import IncompatibleSurrogateError from baybe.parameters import NumericalDiscreteParameter, TaskParameter -from baybe.recommenders import BotorchRecommender +from baybe.recommenders import BayesianRecommender from baybe.searchspace import SearchSpace from baybe.surrogates import CustomONNXSurrogate, GaussianProcessSurrogate from baybe.surrogates.base import Surrogate @@ -272,7 +272,7 @@ def make_surrogate(): task_meas = measurements[measurements["task"] == task_name][ ["x", "y"] ].reset_index(drop=True) - task_recommender = BotorchRecommender( + task_recommender = BayesianRecommender( surrogate_model=make_surrogate(), acquisition_function=acqf, ) @@ -296,7 +296,7 @@ def make_surrogate(): ) else: # Single recommender (single task, or multi-task with transfer learning) - recommender = BotorchRecommender( + recommender = BayesianRecommender( surrogate_model=make_surrogate(), acquisition_function=acqf, ) From d34f290779ae43b25d6a74766dc923931158934f Mon Sep 17 00:00:00 2001 From: StefanPSchmid Date: Fri, 26 Jun 2026 19:28:16 +0200 Subject: [PATCH 08/11] Linting and comment improvements --- .../pure/bayesian/botorch/optimizers/base.py | 9 +++-- .../pure/bayesian/botorch/optimizers/basic.py | 33 +++++++++++-------- .../recommenders/pure/bayesian/continuous.py | 2 +- baybe/recommenders/pure/bayesian/discrete.py | 2 +- baybe/recommenders/pure/bayesian/hybrid.py | 2 +- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py b/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py index 0a1cba3421..443b716e1f 100644 --- a/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py +++ b/baybe/recommenders/pure/bayesian/botorch/optimizers/base.py @@ -2,19 +2,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Protocol, runtime_checkable, TypeAlias, ClassVar from collections.abc import Callable +from typing import TYPE_CHECKING, ClassVar, Protocol, TypeAlias, runtime_checkable from baybe.searchspace import SearchSpace from baybe.searchspace.core import SearchSpaceType -Optimand: TypeAlias = Callable[[Tensor], Tensor] -"Type alias for the callable to be optimized." - if TYPE_CHECKING: - from botorch.acquisition import AcquisitionFunction as BoAcquisitionFunction from torch import Tensor + Optimand: TypeAlias = Callable[[Tensor], Tensor] + "Type alias for the callable to be optimized." + @runtime_checkable class OptimizerProtocol(Protocol): diff --git a/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py b/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py index 0156467d70..eeae1b69fb 100644 --- a/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py +++ b/baybe/recommenders/pure/bayesian/botorch/optimizers/basic.py @@ -4,13 +4,12 @@ import gc from typing import TYPE_CHECKING, ClassVar, cast -from typing_extensions import override from attrs import define, field, fields from attrs.validators import gt, instance_of +from typing_extensions import override -from baybe.exceptions import IncompatibilityError -from baybe.exceptions import IncompatibleSearchSpaceError +from baybe.exceptions import IncompatibilityError, IncompatibleSearchSpaceError from baybe.recommenders.pure.bayesian.botorch.optimizers.base import OptimizerProtocol from baybe.searchspace import SearchSpace from baybe.searchspace.core import SearchSpaceType @@ -18,6 +17,7 @@ if TYPE_CHECKING: from torch import Tensor + from baybe.recommenders.pure.bayesian.botorch.optimizers.base import Optimand @@ -31,12 +31,12 @@ class GradientOptimizer(OptimizerProtocol): n_restarts: int = field(validator=[instance_of(int), gt(0)], default=10) """Number of times gradient-based optimization is restarted from different initial - points. **Does not affect purely discrete optimization**. + points. """ n_raw_samples: int = field(validator=[instance_of(int), gt(0)], default=64) """Number of raw samples drawn for the initialization heuristic in gradient-based - optimization. **Does not affect purely discrete optimization**. + optimization. """ sequential_continuous: bool = field(default=True) @@ -65,27 +65,32 @@ def __call__( The recommendations and corresponding acquisition values. Raises: + IncompatibilityError: If the search space has interpoint constraints and the + ``sequential_continuous`` flag is set to ``True``. NotImplementedError: If the search space has a discrete component. ValueError: If the search space has cardinality constraints. """ import torch - from botorch.optim import optimize_acqf from botorch.acquisition import AcquisitionFunction as BoAcquisitionFunction + from botorch.optim import optimize_acqf if searchspace.type is not self.compatibility: raise IncompatibleSearchSpaceError( - f"'{self.__class__.__name__}' currently only supports " - f"continuous search spaces." - ) + f"'{self.__class__.__name__}' currently only supports " + f"continuous search spaces." + ) # TODO: Add option for automatic choice once the "settings" PR is merged, # which ships the necessary machinery - if self.sequential_continuous and searchspace.continuous.has_interpoint_constraints: + if ( + self.sequential_continuous + and searchspace.continuous.has_interpoint_constraints + ): raise IncompatibilityError( - f"Setting the " - f"'{fields(self.__class__).sequential_continuous.name}' " - f"flag to ``True`` while interpoint constraints are present in the " - f"continuous subspace is not supported. " + f"Setting the " + f"'{fields(self.__class__).sequential_continuous.name}' " + f"flag to ``True`` while interpoint constraints are present in the " + f"continuous subspace is not supported. " ) if not searchspace.discrete.is_empty: diff --git a/baybe/recommenders/pure/bayesian/continuous.py b/baybe/recommenders/pure/bayesian/continuous.py index 4e424ed330..42cb8d9b90 100644 --- a/baybe/recommenders/pure/bayesian/continuous.py +++ b/baybe/recommenders/pure/bayesian/continuous.py @@ -1,4 +1,4 @@ -"""Continuous recommendation routines for BotorchRecommender.""" +"""Continuous recommendation routines for BayesianRecommender.""" from __future__ import annotations diff --git a/baybe/recommenders/pure/bayesian/discrete.py b/baybe/recommenders/pure/bayesian/discrete.py index 9606657408..9d970d5585 100644 --- a/baybe/recommenders/pure/bayesian/discrete.py +++ b/baybe/recommenders/pure/bayesian/discrete.py @@ -1,4 +1,4 @@ -"""Discrete recommendation routines for BotorchRecommender.""" +"""Discrete recommendation routines for BayesianRecommender.""" from __future__ import annotations diff --git a/baybe/recommenders/pure/bayesian/hybrid.py b/baybe/recommenders/pure/bayesian/hybrid.py index e04efaaa70..c82babeea9 100644 --- a/baybe/recommenders/pure/bayesian/hybrid.py +++ b/baybe/recommenders/pure/bayesian/hybrid.py @@ -1,4 +1,4 @@ -"""Hybrid recommendation routines for BotorchRecommender.""" +"""Hybrid recommendation routines for BayesianRecommender.""" from __future__ import annotations From 3c446799ac49cde03381267a73be86446686cc00 Mon Sep 17 00:00:00 2001 From: StefanPSchmid Date: Fri, 26 Jun 2026 19:28:54 +0200 Subject: [PATCH 09/11] Exchange BotorchRecommender with BayesianRecommender throughout repo --- baybe/recommenders/meta/sequential.py | 4 ++-- baybe/recommenders/naive.py | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/baybe/recommenders/meta/sequential.py b/baybe/recommenders/meta/sequential.py index 8f581c74e0..1c560d6f35 100644 --- a/baybe/recommenders/meta/sequential.py +++ b/baybe/recommenders/meta/sequential.py @@ -18,7 +18,7 @@ from baybe.objectives.base import Objective from baybe.recommenders.base import RecommenderProtocol from baybe.recommenders.meta.base import MetaRecommender -from baybe.recommenders.pure.bayesian.botorch import BotorchRecommender +from baybe.recommenders.pure.bayesian import BayesianRecommender from baybe.recommenders.pure.nonpredictive.sampling import RandomRecommender from baybe.searchspace import SearchSpace from baybe.serialization import ( @@ -52,7 +52,7 @@ class TwoPhaseMetaRecommender(MetaRecommender): """The initial recommender used by the meta recommender.""" recommender: RecommenderProtocol = field( - factory=BotorchRecommender, validator=instance_of(RecommenderProtocol) + factory=BayesianRecommender, validator=instance_of(RecommenderProtocol) ) """The recommender used by the meta recommender after the switch.""" diff --git a/baybe/recommenders/naive.py b/baybe/recommenders/naive.py index 5b602d881b..7afefcd76f 100644 --- a/baybe/recommenders/naive.py +++ b/baybe/recommenders/naive.py @@ -10,7 +10,6 @@ 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.botorch import BotorchRecommender from baybe.recommenders.pure.nonpredictive.base import NonPredictiveRecommender from baybe.searchspace import SearchSpace, SearchSpaceType from baybe.utils.dataframe import to_tensor @@ -39,13 +38,13 @@ class NaiveHybridSpaceRecommender(PureRecommender): # works for now. Still, we manually check whether the disc_recommender belongs to # one of these two subclasses such that we might be able to easily spot a potential # problem that might come up when implementing new subclasses of PureRecommender - disc_recommender: PureRecommender = field(factory=BotorchRecommender) + disc_recommender: PureRecommender = field(factory=BayesianRecommender) """The recommender used for the discrete subspace. Default: - :class:`baybe.recommenders.pure.bayesian.botorch.core.BotorchRecommender`""" + :class:`baybe.recommenders.pure.bayesian.base.BayesianRecommender`""" - cont_recommender: BayesianRecommender = field(factory=BotorchRecommender) + cont_recommender: BayesianRecommender = field(factory=BayesianRecommender) """The recommender used for the continuous subspace. Default: - :class:`baybe.recommenders.pure.bayesian.botorch.core.BotorchRecommender`""" + :class:`baybe.recommenders.pure.bayesian.base.BayesianRecommender`""" @override def recommend( From 5d127750521f64f8745fa54b8d9620d8737b4028 Mon Sep 17 00:00:00 2001 From: StefanPSchmid Date: Fri, 26 Jun 2026 19:30:05 +0200 Subject: [PATCH 10/11] Modify init files to import BayesianRecommenders --- baybe/recommenders/__init__.py | 2 ++ baybe/recommenders/pure/__init__.py | 2 ++ baybe/recommenders/pure/bayesian/__init__.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/baybe/recommenders/__init__.py b/baybe/recommenders/__init__.py index 88bc984337..e35da9b05b 100644 --- a/baybe/recommenders/__init__.py +++ b/baybe/recommenders/__init__.py @@ -6,6 +6,7 @@ TwoPhaseMetaRecommender, ) from baybe.recommenders.naive import NaiveHybridSpaceRecommender +from baybe.recommenders.pure.bayesian.base import BayesianRecommender from baybe.recommenders.pure.bayesian.botorch import BotorchRecommender from baybe.recommenders.pure.nonpredictive.clustering import ( GaussianMixtureClusteringRecommender, @@ -18,6 +19,7 @@ ) __all__ = [ + "BayesianRecommender", "BotorchRecommender", "FPSRecommender", "GaussianMixtureClusteringRecommender", diff --git a/baybe/recommenders/pure/__init__.py b/baybe/recommenders/pure/__init__.py index 76340a57ad..1b6d0581d4 100644 --- a/baybe/recommenders/pure/__init__.py +++ b/baybe/recommenders/pure/__init__.py @@ -4,6 +4,7 @@ recommendations. They can be part of meta recommenders. """ +from baybe.recommenders.pure.bayesian.base import BayesianRecommender from baybe.recommenders.pure.bayesian.botorch import BotorchRecommender from baybe.recommenders.pure.nonpredictive import ( FPSRecommender, @@ -14,6 +15,7 @@ ) __all__ = [ + "BayesianRecommender", "BotorchRecommender", "FPSRecommender", "GaussianMixtureClusteringRecommender", diff --git a/baybe/recommenders/pure/bayesian/__init__.py b/baybe/recommenders/pure/bayesian/__init__.py index 9fa0daf758..771cbf0fab 100644 --- a/baybe/recommenders/pure/bayesian/__init__.py +++ b/baybe/recommenders/pure/bayesian/__init__.py @@ -1,7 +1,9 @@ """Bayesian recommenders.""" +from baybe.recommenders.pure.bayesian.base import BayesianRecommender from baybe.recommenders.pure.bayesian.botorch import BotorchRecommender __all__ = [ + "BayesianRecommender", "BotorchRecommender", ] From ccad3c8fc83cf2bf05e66bd4875ac943733dbef2 Mon Sep 17 00:00:00 2001 From: StefanPSchmid Date: Fri, 26 Jun 2026 19:30:48 +0200 Subject: [PATCH 11/11] Deprecate BotorchRecommender --- baybe/recommenders/pure/bayesian/base.py | 25 +- .../pure/bayesian/botorch/continuous.py | 187 ---------- .../pure/bayesian/botorch/core.py | 342 ++++-------------- .../pure/bayesian/botorch/discrete.py | 142 -------- .../pure/bayesian/botorch/hybrid.py | 252 ------------- 5 files changed, 89 insertions(+), 859 deletions(-) delete mode 100644 baybe/recommenders/pure/bayesian/botorch/continuous.py delete mode 100644 baybe/recommenders/pure/bayesian/botorch/discrete.py delete mode 100644 baybe/recommenders/pure/bayesian/botorch/hybrid.py diff --git a/baybe/recommenders/pure/bayesian/base.py b/baybe/recommenders/pure/bayesian/base.py index b3fef1272e..8273fbb454 100644 --- a/baybe/recommenders/pure/bayesian/base.py +++ b/baybe/recommenders/pure/bayesian/base.py @@ -3,14 +3,13 @@ from __future__ import annotations import gc -import warnings from abc import ABC from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, Any import numpy as np import pandas as pd -from attrs import define, field, fields +from attrs import define, field from attrs.converters import optional from attrs.validators import ge, gt, instance_of from typing_extensions import override @@ -24,6 +23,8 @@ ) from baybe.objectives.base import Objective from baybe.recommenders.pure.base import PureRecommender +from baybe.recommenders.pure.bayesian.botorch.optimizers.base import OptimizerProtocol +from baybe.recommenders.pure.bayesian.botorch.optimizers.basic import GradientOptimizer from baybe.recommenders.pure.bayesian.continuous import ( recommend_continuous_torch, ) @@ -35,8 +36,6 @@ recommend_hybrid_with_subsets, recommend_hybrid_without_subsets, ) -from baybe.recommenders.pure.bayesian.botorch.optimizers.base import OptimizerProtocol -from baybe.recommenders.pure.bayesian.botorch.optimizers.basic import GradientOptimizer from baybe.searchspace import ( SearchSpace, SubspaceContinuous, @@ -48,8 +47,8 @@ Surrogate, SurrogateProtocol, ) -from baybe.utils.validation import preprocess_dataframe, validate_object_names from baybe.utils.sampling_algorithms import DiscreteSamplingMethod +from baybe.utils.validation import preprocess_dataframe, validate_object_names if TYPE_CHECKING: from botorch.acquisition import AcquisitionFunction as BoAcquisitionFunction @@ -85,7 +84,7 @@ class BayesianRecommender(PureRecommender, ABC): ) """The acquisition function optimizer.""" - #TODO: Move fields to respective optimizers + # TODO: Move fields to respective optimizers hybrid_sampler: DiscreteSamplingMethod | None = field( converter=optional(DiscreteSamplingMethod), default=None ) @@ -124,6 +123,20 @@ class BayesianRecommender(PureRecommender, ABC): _botorch_acqf = field(default=None, init=False, eq=False) """The induced BoTorch acquisition function.""" + @sampling_percentage.validator + def _validate_percentage( # noqa: DOC101, DOC103 + self, _: Any, value: float + ) -> None: + """Validate that the given value is in fact a percentage. + + Raises: + ValueError: If ``value`` is not between 0 and 1. + """ + if not 0 <= value <= 1: + raise ValueError( + f"Hybrid sampling percentage needs to be between 0 and 1 but is {value}" + ) + def _get_acquisition_function(self, objective: Objective) -> AcquisitionFunction: """Select the appropriate default acquisition function for the given context.""" if self.acquisition_function is None: diff --git a/baybe/recommenders/pure/bayesian/botorch/continuous.py b/baybe/recommenders/pure/bayesian/botorch/continuous.py deleted file mode 100644 index 0fc7a9f7f3..0000000000 --- a/baybe/recommenders/pure/bayesian/botorch/continuous.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Continuous recommendation routines for BotorchRecommender.""" - -from __future__ import annotations - -import warnings -from collections.abc import Callable, Collection, Iterable -from typing import TYPE_CHECKING - -import pandas as pd -from attrs import fields - -from baybe.constraints.utils import is_cardinality_fulfilled -from baybe.exceptions import ( - IncompatibilityError, - MinimumCardinalityViolatedWarning, -) -from baybe.parameters.numerical import _FixedNumericalContinuousParameter -from baybe.searchspace import SubspaceContinuous -from baybe.searchspace.core import SearchSpace - -if TYPE_CHECKING: - from torch import Tensor - - from baybe.recommenders.pure.bayesian.botorch.core import BotorchRecommender - - -def recommend_continuous_torch( - recommender: BotorchRecommender, - subspace_continuous: SubspaceContinuous, - batch_size: int, -) -> tuple[Tensor, Tensor]: - """Dispatcher selecting the continuous optimization routine.""" - if subspace_continuous.n_subsets > 0: - return recommend_continuous_with_cardinality_constraints( - recommender, subspace_continuous, batch_size - ) - else: - return recommend_continuous_without_cardinality_constraints( - recommender, subspace_continuous, batch_size - ) - - -def recommend_continuous_with_cardinality_constraints( - recommender: BotorchRecommender, - subspace_continuous: SubspaceContinuous, - batch_size: int, -) -> tuple[Tensor, Tensor]: - """Recommend from a continuous space with cardinality constraints. - - Optimizes the acquisition function across subsets defined by cardinality - constraints and returns the best result. - - The specific collection of subsets considered by the recommender is obtained - as either the full combinatorial set of possible parameter splits or a random - selection thereof, depending on the upper bound specified by the corresponding - recommender attribute. - - In each subset, the constraint-imposed configuration is fixed, so that the - constraints can be removed and a regular optimization can be performed. The - recommendation is then constructed from the combined optimization results of the - unconstrained spaces. - - Args: - recommender: The recommender instance. - subspace_continuous: The continuous subspace from which to generate - recommendations. - batch_size: The size of the recommendation batch. - - Returns: - The recommendations and corresponding acquisition values. - - Raises: - ValueError: If the continuous search space has no cardinality - constraints. - """ - if subspace_continuous.n_subsets == 0: - raise ValueError( - f"'{recommend_continuous_with_cardinality_constraints.__name__}' " - f"expects a subspace with cardinality constraints." - ) - - # Determine search scope based on number of subset configurations - configs: Iterable[frozenset[str]] - if subspace_continuous.n_subsets <= recommender.max_n_subsets: - configs = subspace_continuous.inactive_parameter_combinations() - else: - configs = subspace_continuous._sample_inactive_parameters( - recommender.max_n_subsets - ) - - # Create closures for each subset configuration - def make_callable( - inactive_params: Collection[str], - ) -> Callable[[], tuple[Tensor, Tensor]]: - def optimize() -> tuple[Tensor, Tensor]: - import torch - - sub = subspace_continuous._enforce_cardinality_constraints(inactive_params) - # Note: We explicitly evaluate the acqf function for the batch - # because the object returned by the optimization routine may - # contain joint or individual acquisition values, depending on - # whether sequential or joint optimization is applied - p, _ = recommend_continuous_torch(recommender, sub, batch_size) - with torch.no_grad(): - acqf_value = recommender._botorch_acqf(p) - return p, acqf_value - - return optimize - - callables = (make_callable(ip) for ip in configs) - points, acqf_value = recommender._optimize_over_subsets(callables) - - # Check if any minimum cardinality constraints are violated - if not is_cardinality_fulfilled( - pd.DataFrame(points, columns=subspace_continuous.parameter_names), - subspace_continuous, - check_maximum=False, - ): - warnings.warn( - "At least one minimum cardinality constraint has been violated. " - "This may occur when parameter ranges extend beyond zero in both " - "directions, making the feasible region non-convex. For such " - "parameters, minimum cardinality constraints are currently not " - "enforced due to the complexity of the resulting optimization problem.", - MinimumCardinalityViolatedWarning, - ) - - return points, acqf_value - - -def recommend_continuous_without_cardinality_constraints( - recommender: BotorchRecommender, - subspace_continuous: SubspaceContinuous, - batch_size: int, -) -> tuple[Tensor, Tensor]: - """Recommend from a continuous search space without cardinality constraints. - - Args: - recommender: The recommender instance. - subspace_continuous: The continuous subspace from which to generate - recommendations. - batch_size: The size of the recommendation batch. - - Returns: - The recommendations and corresponding acquisition values. - - Raises: - ValueError: If the continuous search space has cardinality constraints. - """ - if subspace_continuous.n_subsets > 0: - raise ValueError( - f"'{recommend_continuous_without_cardinality_constraints.__name__}' " - f"expects a subspace without cardinality constraints." - ) - - fixed_parameters = { - idx: p.value - for (idx, p) in enumerate(subspace_continuous.parameters) - if isinstance(p, _FixedNumericalContinuousParameter) - } - - # TODO: Add option for automatic choice once the "settings" PR is merged, - # which ships the necessary machinery - if ( - recommender.sequential_continuous - and subspace_continuous.has_interpoint_constraints - ): - from baybe.recommenders.pure.bayesian.botorch.core import BotorchRecommender - - raise IncompatibilityError( - f"Setting the " - f"'{fields(BotorchRecommender).sequential_continuous.name}' " - f"flag to ``True`` while interpoint constraints are present in the " - f"continuous subspace is not supported. " - ) - - # NOTE: The explicit `or None` conversion is added as an additional safety net - # because it is unclear if the corresponding presence checks for these - # arguments is correctly implemented in all invoked BoTorch subroutines. - # For details: https://github.com/pytorch/botorch/issues/2042 - points, acqf_values = recommender.optimizer( - batch_size=batch_size, - acquisition_function=recommender._botorch_acqf, - searchspace=SearchSpace(continuous=subspace_continuous), - fixed_parameters=fixed_parameters, - ) - return points, acqf_values diff --git a/baybe/recommenders/pure/bayesian/botorch/core.py b/baybe/recommenders/pure/bayesian/botorch/core.py index 736ba81554..205e8cb8c2 100644 --- a/baybe/recommenders/pure/bayesian/botorch/core.py +++ b/baybe/recommenders/pure/bayesian/botorch/core.py @@ -4,297 +4,95 @@ import gc import warnings -from collections.abc import Callable, Iterable -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING -import numpy as np -import pandas as pd -from attrs import define, field -from attrs.converters import optional as optional_c -from attrs.validators import ge, gt, instance_of -from typing_extensions import override - -from baybe.exceptions import ( - IncompatibleAcquisitionFunctionError, - InfeasibilityError, -) -from baybe.recommenders.pure.bayesian.base import BayesianRecommender -from baybe.recommenders.pure.bayesian.botorch.continuous import ( - recommend_continuous_torch, -) -from baybe.recommenders.pure.bayesian.botorch.discrete import ( - recommend_discrete_with_subsets, - recommend_discrete_without_subsets, -) -from baybe.recommenders.pure.bayesian.botorch.hybrid import ( - recommend_hybrid_with_subsets, - recommend_hybrid_without_subsets, -) +from baybe.acquisition.base import AcquisitionFunction from baybe.recommenders.pure.bayesian.botorch.optimizers.basic import GradientOptimizer -from baybe.searchspace import ( - SearchSpace, - SearchSpaceType, - SubspaceContinuous, - SubspaceDiscrete, -) -from baybe.utils.conversion import to_string +from baybe.surrogates.base import SurrogateProtocol +from baybe.surrogates.gaussian_process.core import GaussianProcessSurrogate from baybe.utils.sampling_algorithms import DiscreteSamplingMethod if TYPE_CHECKING: - from torch import Tensor - - -@define(kw_only=True) -class BotorchRecommender(BayesianRecommender): - """A pure recommender utilizing Botorch's optimization machinery. - - This recommender makes use of Botorch's ``optimize_acqf_discrete``, - ``optimize_acqf`` and ``optimize_acqf_mixed`` functions to optimize discrete, - continuous and hybrid search spaces, respectively. Accordingly, it can be applied to - all kinds of search spaces. - - Note: - In hybrid search spaces, the used algorithm performs a brute-force optimization - that can be computationally expensive. Thus, the behavior of the algorithm in - hybrid search spaces can be controlled via two additional parameters. - """ - - # Class variables - compatibility: ClassVar[SearchSpaceType] = SearchSpaceType.HYBRID - # See base class. - - supports_discrete_subset_generating_constraints: ClassVar[bool] = True - # See base class. - - # Object variables - sequential_continuous: bool = field(default=True) - """Flag defining whether to apply sequential greedy or batch optimization in - **continuous** search spaces. In discrete/hybrid spaces, sequential greedy - optimization is applied automatically. - """ - - hybrid_sampler: DiscreteSamplingMethod | None = field( - converter=optional_c(DiscreteSamplingMethod), default=None - ) - """Strategy used for sampling the discrete subspace when performing hybrid search - space optimization.""" - - sampling_percentage: float = field(default=1.0) - """Percentage of discrete search space that is sampled when performing hybrid search - space optimization. Ignored when ``hybrid_sampler="None"``.""" - - n_restarts: int = field(validator=[instance_of(int), gt(0)], default=10) - """Number of times gradient-based optimization is restarted from different initial - points. **Does not affect purely discrete optimization**. - """ - - n_raw_samples: int = field(validator=[instance_of(int), gt(0)], default=64) - """Number of raw samples drawn for the initialization heuristic in gradient-based - optimization. **Does not affect purely discrete optimization**. + from baybe.recommenders.pure.bayesian.base import BayesianRecommender + + +def BotorchRecommender( + *, + surrogate_model: SurrogateProtocol = GaussianProcessSurrogate(), + acquisition_function: AcquisitionFunction | None = None, + sequential_continuous: bool = True, + hybrid_sampler: DiscreteSamplingMethod | None = None, + sampling_percentage: float = 1.0, + n_restarts: int = 10, + n_raw_samples: int = 64, + max_n_subsets: int = 10, + max_n_subspaces: int | None = None, +) -> BayesianRecommender: + """Use factory function for BotorchRecommender deprecation. + + This recommender will be deprecated in a future version. + This function provides the interface for creating a BayesianRecommender + based on a BotorchRecommender. + + Args: + surrogate_model: The surrogate model to be used. + acquisition_function: The acquisition function to be used. + sequential_continuous: See :class:`BayesianRecommender`. + hybrid_sampler: See :class:`BayesianRecommender`. + sampling_percentage: See :class:`BayesianRecommender`. + n_restarts: See :class:`BayesianRecommender`. + n_raw_samples: See :class:`BayesianRecommender`. + max_n_subsets: See :class:`BayesianRecommender`. + max_n_subspaces: Deprecated! Use ``max_n_subsets`` instead. + + Returns: + BayesianRecommender: Instance of `BayesianRecommender` with provided parameters. """ + from baybe.recommenders.pure.bayesian.base import BayesianRecommender - max_n_subsets: int = field(default=10, validator=[instance_of(int), ge(1)]) - """Maximum number of subsets to evaluate when subset-generating constraints are - present (e.g., continuous cardinality constraints). If the total number of - subsets exceeds this limit, a random subset of that size is sampled for - optimization instead of performing an exhaustive search.""" - - @property - def max_n_subspaces(self) -> int: - """Deprecated! Use ``max_n_subsets`` instead.""" + if max_n_subspaces is not None: warnings.warn( "'max_n_subspaces' has been renamed to 'max_n_subsets' and will " "be removed in a future version.", DeprecationWarning, stacklevel=2, ) - return self.max_n_subsets + max_n_subsets = max_n_subspaces - @max_n_subspaces.setter - def max_n_subspaces(self, value: int) -> None: - """Deprecated! Use ``max_n_subsets`` instead.""" # noqa: D401 - warnings.warn( - "'max_n_subspaces' has been renamed to 'max_n_subsets' and will " - "be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) - self.max_n_subsets = value - - @sampling_percentage.validator - def _validate_percentage( # noqa: DOC101, DOC103 - self, _: Any, value: float - ) -> None: - """Validate that the given value is in fact a percentage. - - Raises: - ValueError: If ``value`` is not between 0 and 1. - """ - if not 0 <= value <= 1: - raise ValueError( - f"Hybrid sampling percentage needs to be between 0 and 1 but is {value}" - ) + warnings.warn( + "'BotorchRecommender' is deprecated and will be removed in a future version. " + "Please use 'BayesianRecommender' instead.", + DeprecationWarning, + stacklevel=2, + ) - @override - def __str__(self) -> str: - fields = [ - to_string("Surrogate", self._surrogate_model), - to_string( - "Acquisition function", self.acquisition_function, single_line=True + # TODO: Clean up once more optimizers are implemented. + if not sequential_continuous: + return BayesianRecommender( + surrogate_model=surrogate_model, + acquisition_function=acquisition_function, + optimizer=GradientOptimizer( + n_restarts=n_restarts, + n_raw_samples=n_raw_samples, + sequential_continuous=sequential_continuous, ), - to_string("Compatibility", self.compatibility, single_line=True), - to_string( - "Sequential continuous", self.sequential_continuous, single_line=True - ), - to_string("Hybrid sampler", self.hybrid_sampler, single_line=True), - to_string( - "Sampling percentage", self.sampling_percentage, single_line=True - ), - ] - return to_string(self.__class__.__name__, *fields) - - @override - def _recommend_discrete( - self, - subspace_discrete: SubspaceDiscrete, - candidates_exp: pd.DataFrame, - batch_size: int, - ) -> pd.Index: - """Generate recommendations from a discrete search space. - - Dispatches to the appropriate optimization routine depending on whether - subset constraints are present. - - Args: - subspace_discrete: The discrete subspace from which to generate - recommendations. - candidates_exp: The experimental representation of all discrete candidate - points to be considered. - batch_size: The size of the recommendation batch. - - Returns: - The dataframe indices of the recommended points in the provided - experimental representation. - """ - if subspace_discrete.n_subsets > 0: - return recommend_discrete_with_subsets( - self, subspace_discrete, candidates_exp, batch_size - ) - return recommend_discrete_without_subsets( - self, subspace_discrete, candidates_exp, batch_size + hybrid_sampler=hybrid_sampler, + sampling_percentage=sampling_percentage, + n_restarts=n_restarts, + n_raw_samples=n_raw_samples, + max_n_subsets=max_n_subsets, ) - - @override - def _recommend_continuous( - self, - subspace_continuous: SubspaceContinuous, - batch_size: int, - ) -> pd.DataFrame: - """Generate recommendations from a continuous search space. - - Args: - subspace_continuous: The continuous subspace from which to generate - recommendations. - batch_size: The size of the recommendation batch. - - Raises: - IncompatibleAcquisitionFunctionError: If a non-Monte Carlo acquisition - function is used with a batch size > 1. - - Returns: - A dataframe containing the recommendations as individual rows. - """ - assert self._objective is not None - if ( - batch_size > 1 - and not self._get_acquisition_function(self._objective).supports_batching - ): - raise IncompatibleAcquisitionFunctionError( - f"The '{self.__class__.__name__}' only works with Monte Carlo " - f"acquisition functions for batch sizes > 1." - ) - - points, _ = recommend_continuous_torch(self, subspace_continuous, batch_size) - - return pd.DataFrame(points, columns=subspace_continuous.parameter_names) - - @override - def _recommend_hybrid( - self, - searchspace: SearchSpace, - candidates_exp: pd.DataFrame, - batch_size: int, - ) -> pd.DataFrame: - """Generate recommendations from a hybrid search space. - - Dispatches to the appropriate optimization routine depending on whether - subset constraints are present. - - Args: - searchspace: The search space in which the recommendations should be made. - candidates_exp: The experimental representation of the candidates - of the discrete subspace. - batch_size: The size of the calculated batch. - - Returns: - The recommended points. - """ - if searchspace.n_subsets > 0: - return recommend_hybrid_with_subsets( - self, searchspace, candidates_exp, batch_size - ) - return recommend_hybrid_without_subsets( - self, searchspace, candidates_exp, batch_size + else: + return BayesianRecommender( + surrogate_model=surrogate_model, + acquisition_function=acquisition_function, + hybrid_sampler=hybrid_sampler, + sampling_percentage=sampling_percentage, + n_restarts=n_restarts, + n_raw_samples=n_raw_samples, + max_n_subsets=max_n_subsets, ) - @override - def _optimize_over_subsets( - self, - subset_callables: Iterable[Callable[[], tuple[Any, Tensor]]], - ) -> tuple[Any, Tensor]: - """Optimize across subsets and return the result with the best acqf value. - - Each callable performs optimization for one subset configuration and returns - a ``(result, acquisition_value)`` tuple. Subsets that raise - ``InfeasibilityError`` are silently skipped. - - Args: - subset_callables: An iterable of zero-argument callables. Each callable - runs the optimization for one subset and returns - ``(result, acqf_value)``. It may raise ``InfeasibilityError`` if the - subset is infeasible. - - Raises: - InfeasibilityError: If none of the subsets has a feasible solution. - - Returns: - The result and acquisition value of the best subset. - """ - from botorch.exceptions.errors import InfeasibilityError as BoInfeasibilityError - - results_all: list = [] - acqf_values_all: list[Tensor] = [] - - for optimize_fn in subset_callables: - try: - result, acqf_value = optimize_fn() - results_all.append(result) - acqf_values_all.append(acqf_value) - except (BoInfeasibilityError, InfeasibilityError): - pass - - if not results_all: - raise InfeasibilityError( - "No feasible solution could be found. Potentially the specified " - "constraints are too restrictive, i.e. there may be too many " - "constraints or thresholds may have been set too tightly. " - "Consider relaxing the constraints to improve the chances " - "of finding a feasible solution." - ) - - best_idx = np.argmax(acqf_values_all) - return results_all[best_idx], acqf_values_all[best_idx] - # Collect leftover original slotted classes processed by `attrs.define` gc.collect() diff --git a/baybe/recommenders/pure/bayesian/botorch/discrete.py b/baybe/recommenders/pure/bayesian/botorch/discrete.py deleted file mode 100644 index a5f92d04d0..0000000000 --- a/baybe/recommenders/pure/bayesian/botorch/discrete.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Discrete recommendation routines for BotorchRecommender.""" - -from __future__ import annotations - -from collections.abc import Callable, Iterable -from typing import TYPE_CHECKING - -import numpy as np -import numpy.typing as npt -import pandas as pd - -from baybe.searchspace import SubspaceDiscrete -from baybe.utils.dataframe import to_tensor - -if TYPE_CHECKING: - from torch import Tensor - - from baybe.recommenders.pure.bayesian.botorch.core import BotorchRecommender - - -def recommend_discrete_with_subsets( - recommender: BotorchRecommender, - subspace_discrete: SubspaceDiscrete, - candidates_exp: pd.DataFrame, - batch_size: int, -) -> pd.Index: - """Recommend from a discrete space with subset-generating constraints. - - Splits the candidate set into subsets according to subset-generating constraints, - runs optimization on each feasible subset, and returns the batch with - the highest joint acquisition value. Subsets with fewer candidates - than ``batch_size`` are skipped. - - Args: - recommender: The recommender instance. - subspace_discrete: The discrete subspace from which to generate - recommendations. - candidates_exp: The experimental representation of candidates. - batch_size: The size of the recommendation batch. - - Returns: - The dataframe indices of the recommended points. - """ - import torch - - masks: Iterable[npt.NDArray[np.bool_]] - if subspace_discrete.n_subsets <= recommender.max_n_subsets: - masks = subspace_discrete.subset_masks( - candidates_exp, min_candidates=batch_size - ) - else: - masks = subspace_discrete.sample_subset_masks( - candidates_exp, recommender.max_n_subsets, min_candidates=batch_size - ) - - def make_callable( - mask: np.ndarray, - ) -> Callable[[], tuple[pd.Index, Tensor]]: - def optimize() -> tuple[pd.Index, Tensor]: - subset = candidates_exp.loc[mask] - - idxs = recommend_discrete_without_subsets( - recommender, subspace_discrete, subset, batch_size - ) - - comp = subspace_discrete.transform(candidates_exp.loc[idxs]) - with torch.no_grad(): - acqf_value = recommender._botorch_acqf(to_tensor(comp).unsqueeze(0)) - return idxs, acqf_value - - return optimize - - callables = (make_callable(m) for m in masks) - best_idxs, _ = recommender._optimize_over_subsets(callables) - return best_idxs - - -def recommend_discrete_without_subsets( - recommender: BotorchRecommender, - subspace_discrete: SubspaceDiscrete, - candidates_exp: pd.DataFrame, - batch_size: int, -) -> pd.Index: - """Generate recommendations from a discrete search space. - - Args: - recommender: The recommender instance. - subspace_discrete: The discrete subspace from which to generate - recommendations. - candidates_exp: The experimental representation of all discrete candidate - points to be considered. - batch_size: The size of the recommendation batch. - - Raises: - IncompatibleAcquisitionFunctionError: If a non-Monte Carlo acquisition - function is used with a batch size > 1. - - Returns: - The dataframe indices of the recommended points in the provided - experimental representation. - """ - from baybe.acquisition.acqfs import qThompsonSampling - from baybe.exceptions import ( - IncompatibilityError, - IncompatibleAcquisitionFunctionError, - ) - - assert recommender._objective is not None - acqf = recommender._get_acquisition_function(recommender._objective) - if batch_size > 1 and not acqf.supports_batching: - raise IncompatibleAcquisitionFunctionError( - f"The '{recommender.__class__.__name__}' only works with Monte Carlo " - f"acquisition functions for batch sizes > 1." - ) - if batch_size > 1 and isinstance(acqf, qThompsonSampling): - raise IncompatibilityError( - "Thompson sampling currently only supports a batch size of 1." - ) - - from botorch.optim import optimize_acqf_discrete - - # determine the next set of points to be tested - candidates_comp = subspace_discrete.transform(candidates_exp) - points, _ = optimize_acqf_discrete( - recommender._botorch_acqf, batch_size, to_tensor(candidates_comp) - ) - - # retrieve the index of the points from the input dataframe - # IMPROVE: The merging procedure is conceptually similar to what - # `SearchSpace._match_measurement_with_searchspace_indices` does, though using - # a simpler matching logic. When refactoring the SearchSpace class to - # handle continuous parameters, a corresponding utility could be extracted. - idxs = pd.Index( - pd.merge( - pd.DataFrame(points, columns=candidates_comp.columns), - candidates_comp.reset_index(), - on=list(candidates_comp), - how="left", - )["index"] - ) - - return idxs diff --git a/baybe/recommenders/pure/bayesian/botorch/hybrid.py b/baybe/recommenders/pure/bayesian/botorch/hybrid.py deleted file mode 100644 index 0b017a92dd..0000000000 --- a/baybe/recommenders/pure/bayesian/botorch/hybrid.py +++ /dev/null @@ -1,252 +0,0 @@ -"""Hybrid recommendation routines for BotorchRecommender.""" - -from __future__ import annotations - -import math -import warnings -from collections.abc import Callable, Iterable -from typing import TYPE_CHECKING - -import numpy as np -import pandas as pd - -from baybe.constraints.utils import is_cardinality_fulfilled -from baybe.exceptions import ( - IncompatibilityError, - IncompatibleAcquisitionFunctionError, - MinimumCardinalityViolatedWarning, -) -from baybe.searchspace import SearchSpace -from baybe.utils.basic import flatten -from baybe.utils.dataframe import to_tensor -from baybe.utils.sampling_algorithms import sample_numerical_df - -if TYPE_CHECKING: - from torch import Tensor - - from baybe.recommenders.pure.bayesian.botorch.core import BotorchRecommender - - -def recommend_hybrid_without_subsets( - recommender: BotorchRecommender, - searchspace: SearchSpace, - candidates_exp: pd.DataFrame, - batch_size: int, -) -> pd.DataFrame: - """Recommend points using the ``optimize_acqf_mixed`` function of BoTorch. - - This functions samples points from the discrete subspace, performs optimization - in the continuous subspace with these points being fixed and returns the best - found solution. - - **Important**: This performs a brute-force calculation by fixing every possible - assignment of discrete variables and optimizing the continuous subspace for - each of them. It is thus computationally expensive. - - **Note**: This function implicitly assumes that discrete search space parts in - the respective data frame come first and continuous parts come second. - - Args: - recommender: The recommender instance. - searchspace: The search space in which the recommendations should be made. - candidates_exp: The experimental representation of the candidates - of the discrete subspace. - batch_size: The size of the calculated batch. - - Raises: - IncompatibleAcquisitionFunctionError: If a non-Monte Carlo acquisition - function is used with a batch size > 1. - - Returns: - The recommended points. - """ - assert recommender._objective is not None - - # Interpoint constraints cannot be used with optimize_acqf_mixed, see - # https://github.com/meta-pytorch/botorch/issues/2996 - if searchspace.continuous.has_interpoint_constraints: - raise IncompatibilityError( - "Interpoint constraints are not available in hybrid spaces." - ) - if ( - batch_size > 1 - and not recommender._get_acquisition_function( - recommender._objective - ).supports_batching - ): - raise IncompatibleAcquisitionFunctionError( - f"The '{recommender.__class__.__name__}' only works with Monte Carlo " - f"acquisition functions for batch sizes > 1." - ) - - import torch - from botorch.optim import optimize_acqf_mixed - - # Transform discrete candidates - candidates_comp = searchspace.discrete.transform(candidates_exp) - - # Calculate the number of samples from the given percentage - n_candidates = math.ceil( - recommender.sampling_percentage * len(candidates_comp.index) - ) - - # Potential sampling of discrete candidates - if recommender.hybrid_sampler is not None: - candidates_comp = sample_numerical_df( - candidates_comp, n_candidates, method=recommender.hybrid_sampler - ) - - # Prepare all considered discrete configurations in the - # List[Dict[int, float]] format expected by BoTorch. - num_comp_columns = len(candidates_comp.columns) - candidates_comp.columns = list(range(num_comp_columns)) - fixed_features_list = candidates_comp.to_dict("records") - - # Actual call of the BoTorch optimization routine - # NOTE: The explicit `or None` conversion is added as an additional safety net - # because it is unclear if the corresponding presence checks for these - # arguments is correctly implemented in all invoked BoTorch subroutines. - # For details: https://github.com/pytorch/botorch/issues/2042 - points, _ = optimize_acqf_mixed( - acq_function=recommender._botorch_acqf, - bounds=torch.from_numpy(searchspace.comp_rep_bounds.to_numpy(copy=True)), - q=batch_size, - num_restarts=recommender.n_restarts, - raw_samples=recommender.n_raw_samples, - fixed_features_list=fixed_features_list, # type: ignore[arg-type] - equality_constraints=flatten( - c.to_botorch( - searchspace.continuous.parameters, - idx_offset=len(candidates_comp.columns), - batch_size=batch_size if c.is_interpoint else None, - ) - for c in searchspace.continuous.constraints_lin_eq - ) - or None, - inequality_constraints=flatten( - c.to_botorch( - searchspace.continuous.parameters, - idx_offset=num_comp_columns, - batch_size=batch_size if c.is_interpoint else None, - ) - for c in searchspace.continuous.constraints_lin_ineq - ) - or None, - ) - - # Align candidates with search space index. Done via including the search space - # index during the merge, which is used later for back-translation into the - # experimental representation - merged = pd.merge( - pd.DataFrame(points), - candidates_comp.reset_index(), - on=list(candidates_comp.columns), - how="left", - ).set_index("index") - - # Get experimental representation of discrete part - rec_disc_exp = searchspace.discrete.exp_rep.loc[merged.index] - - # Combine discrete and continuous parts - rec_exp = pd.concat( - [ - rec_disc_exp, - merged.iloc[:, num_comp_columns:].set_axis( - searchspace.continuous.parameter_names, axis=1 - ), - ], - axis=1, - ) - - return rec_exp - - -def recommend_hybrid_with_subsets( - recommender: BotorchRecommender, - searchspace: SearchSpace, - candidates_exp: pd.DataFrame, - batch_size: int, -) -> pd.DataFrame: - """Recommend from a hybrid space with subset constraints. - - Uses ``SearchSpace.subsets()`` to enumerate the Cartesian - product of discrete and continuous subset configurations, capped at - ``max_n_subsets`` total. In purely discrete search spaces, subsets - with fewer candidates than ``batch_size`` are pre-filtered. - - Args: - recommender: The recommender instance. - searchspace: The search space in which the recommendations should be made. - candidates_exp: The experimental representation of the candidates - of the discrete subspace. - batch_size: The size of the calculated batch. - - Returns: - The recommended points. - """ - from attrs import evolve - - subspace_c = searchspace.continuous - - # Get combined configurations, capped at max_n_subsets - # NOTE: No min_discrete_candidates filtering in hybrid spaces because - # optimize_acqf_mixed can produce multiple recommendations from a single - # discrete candidate by varying continuous parameters. - combined_masks: Iterable[tuple[np.ndarray, frozenset[str]]] - if searchspace.n_subsets <= recommender.max_n_subsets: - combined_masks = searchspace.subsets(candidates_exp) - else: - combined_masks = searchspace.sample_subsets( - candidates_exp, recommender.max_n_subsets - ) - - def make_callable( - d_mask: np.ndarray, - c_inactive_params: frozenset[str], - ) -> Callable[[], tuple[pd.DataFrame, Tensor]]: - def optimize() -> tuple[pd.DataFrame, Tensor]: - import torch - - subset = candidates_exp.loc[d_mask] - - if c_inactive_params: - mod_cont = subspace_c._enforce_cardinality_constraints( - c_inactive_params - ) - else: - mod_cont = subspace_c - mod_searchspace = evolve(searchspace, continuous=mod_cont) - - rec = recommend_hybrid_without_subsets( - recommender, mod_searchspace, subset, batch_size - ) - - comp = mod_searchspace.transform(rec) - with torch.no_grad(): - acqf_value = recommender._botorch_acqf( - to_tensor(comp.values).unsqueeze(0) - ) - return rec, acqf_value - - return optimize - - callables = (make_callable(d_mask, c_ip) for d_mask, c_ip in combined_masks) - best_rec, _ = recommender._optimize_over_subsets(callables) - - # Post-check minimum cardinality on continuous columns - if subspace_c.constraints_cardinality and not is_cardinality_fulfilled( - best_rec[list(subspace_c.parameter_names)], - subspace_c, - check_maximum=False, - ): - warnings.warn( - "At least one minimum cardinality constraint has been violated. " - "This may occur when parameter ranges extend beyond zero in both " - "directions, making the feasible region non-convex. For such " - "parameters, minimum cardinality constraints are currently not " - "enforced due to the complexity of the resulting optimization " - "problem.", - MinimumCardinalityViolatedWarning, - ) - - return best_rec