Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
```

Expand Down
2 changes: 2 additions & 0 deletions baybe/recommenders/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,6 +19,7 @@
)

__all__ = [
"BayesianRecommender",
"BotorchRecommender",
"FPSRecommender",
"GaussianMixtureClusteringRecommender",
Expand Down
4 changes: 2 additions & 2 deletions baybe/recommenders/meta/sequential.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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."""

Expand Down
9 changes: 4 additions & 5 deletions baybe/recommenders/naive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions baybe/recommenders/pure/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,6 +15,7 @@
)

__all__ = [
"BayesianRecommender",
"BotorchRecommender",
"FPSRecommender",
"GaussianMixtureClusteringRecommender",
Expand Down
2 changes: 2 additions & 0 deletions baybe/recommenders/pure/bayesian/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
215 changes: 213 additions & 2 deletions baybe/recommenders/pure/bayesian/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,55 @@

import gc
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
from attrs.converters import optional
from attrs.validators import ge, gt, instance_of
from typing_extensions import override

from baybe.acquisition import qLogEI, qLogNEHVI
from baybe.acquisition.base import AcquisitionFunction
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.searchspace import SearchSpace
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,
)
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.searchspace import (
SearchSpace,
SubspaceContinuous,
SubspaceDiscrete,
)
from baybe.settings import Settings
from baybe.surrogates import GaussianProcessSurrogate
from baybe.surrogates.base import (
Surrogate,
SurrogateProtocol,
)
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
from torch import Tensor


def _autoreplicate(surrogate: SurrogateProtocol, /) -> SurrogateProtocol:
Expand All @@ -55,6 +78,39 @@ class BayesianRecommender(PureRecommender, ABC):
)
"""The acquisition function. When omitted, a default is used."""

optimizer: OptimizerProtocol = field(
alias="optimizer",
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
Expand All @@ -67,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:
Expand Down Expand Up @@ -196,6 +266,147 @@ def recommend(
else:
raise

@override
def _recommend_discrete(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dispatching logic moved from BotorchRecommender to BayesianRecommender to prepare for removal of BotorchRecommender as discussed

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just one question: Do we then still need the @OverRide annotations? I do not think that those internal functions specialized to the individual search space types are defined on the level of the PureRecommender, could you verify?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are functions with that name in the PureRecommender, but they raise NonImplementedErrors

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,
Expand Down
Loading
Loading