Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move allow_* flags to Campaign #423

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
71e42c3
Move allow_* flags to campaign class
AdrianSosic Nov 9, 2024
c65d508
Update flag handling in tests
AdrianSosic Nov 9, 2024
4bfced7
Update flag handling in examples
AdrianSosic Nov 10, 2024
52a6604
Move flag description in user guide
AdrianSosic Nov 10, 2024
396ff84
Move pending experiments flag to campaign class
AdrianSosic Nov 13, 2024
24d3ef1
Update admonition mentioning pending experiments flag
AdrianSosic Nov 13, 2024
74206d8
Remove pending experiments flag from tests
AdrianSosic Nov 13, 2024
94ec960
Rename _annotated.py to _filtered.py
AdrianSosic Nov 13, 2024
4bdde65
Remove exclude argument from get_candidates
AdrianSosic Nov 13, 2024
ce18c58
Draft FilteredSubspaceDiscrete class
AdrianSosic Nov 13, 2024
f56eaf3
Harmonize allow_* flags
AdrianSosic Nov 13, 2024
17a858e
Add deprecation mechanism for allow_* flags
AdrianSosic Nov 15, 2024
087abf8
Implement context-aware validation of allow_* flags
AdrianSosic Nov 19, 2024
4a0f56b
Fix flag handling in tests
AdrianSosic Nov 20, 2024
b98ba79
Introduce UNSPECIFIED sentinel for correct Boolean conversion
AdrianSosic Nov 20, 2024
865813a
Add TODO note
AdrianSosic Nov 20, 2024
b9fa97b
Drop pending measurements from search space before recommending
AdrianSosic Nov 21, 2024
805649c
Update CHANGELOG.md
AdrianSosic Nov 21, 2024
ffe2b27
Drop unnecessary parts from changelog
AdrianSosic Nov 25, 2024
1fef6da
Mention possible relaxations when not enough candidates remain
AdrianSosic Nov 26, 2024
c6ec66f
Consider both Boolean values for flag test
AdrianSosic Nov 26, 2024
e366077
Ignore missing sphinx reference
AdrianSosic Nov 26, 2024
dfa7a70
Add BotorchRecommender to deprecation test
AdrianSosic Nov 26, 2024
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `CustomDiscreteParameter` does not allow duplicated rows in `data` anymore
- De-/activating Polars via `BAYBE_DEACTIVATE_POLARS` now requires passing values
compatible with `strtobool`
- `allow_repeated_recommendations` has been renamed to
`allow_recommending_already_recommended`

### Fixed
- Rare bug arising from degenerate `SubstanceParameter.comp_df` rows that caused
Expand All @@ -30,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Search spaces are now stateless, preventing unintended side effects that could lead to
incorrect candidate sets when reused in different optimization contexts
- `qNIPV` not working with single `MIN` targets
- `allow_*` flags are now context-aware, i.e. setting them in a context where they are
irrelevant now raises an error instead of passing silently

### Deprecations
- Passing a dataframe via the `data` argument to `Objective.transform` is no longer
Expand All @@ -44,6 +48,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `SubstanceEncoding` value `RDKIT`. As a replacement, `RDKIT2DDESCRIPTORS` can be used.
- The `metadata` attribute of `SubspaceDiscrete` no longer exists. Metadata is now
exclusively handled by the `Campaign` class.
- Passing `allow_*` flags to recommenders is no longer supported since the necessary
metadata required for the flags is no longer available at that level. The
functionality has been taken over by `Campaign`.

## [0.11.3] - 2024-11-06
### Fixed
Expand Down
150 changes: 131 additions & 19 deletions baybe/campaign.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,27 @@

import gc
import json
from collections.abc import Collection
from collections.abc import Callable, Collection
from functools import reduce
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

import cattrs
import numpy as np
import pandas as pd
from attrs import define, evolve, field
from attrs import Attribute, Factory, define, evolve, field, fields
from attrs.converters import optional
from attrs.validators import instance_of
from typing_extensions import override

from baybe.constraints.base import DiscreteConstraint
from baybe.exceptions import IncompatibilityError
from baybe.exceptions import IncompatibilityError, NotEnoughPointsLeftError
from baybe.objectives.base import Objective, to_objective
from baybe.parameters.base import Parameter
from baybe.recommenders.base import RecommenderProtocol
from baybe.recommenders.meta.base import MetaRecommender
from baybe.recommenders.meta.sequential import TwoPhaseMetaRecommender
from baybe.recommenders.pure.bayesian.base import BayesianRecommender
from baybe.searchspace._annotated import AnnotatedSubspaceDiscrete
from baybe.searchspace._filtered import FilteredSubspaceDiscrete
from baybe.searchspace.core import (
SearchSpace,
SearchSpaceType,
Expand All @@ -39,7 +39,7 @@
telemetry_record_recommended_measurement_percentage,
telemetry_record_value,
)
from baybe.utils.basic import is_all_instance
from baybe.utils.basic import UNSPECIFIED, UnspecifiedType, is_all_instance
from baybe.utils.boolean import eq_dataframe
from baybe.utils.dataframe import filter_df, fuzzy_row_match
from baybe.utils.plotting import to_string
Expand All @@ -54,6 +54,38 @@
_METADATA_COLUMNS = [_RECOMMENDED, _MEASURED, _EXCLUDED]


def _make_allow_flag_default_factory(
default: bool,
) -> Callable[[Campaign], bool | UnspecifiedType]:
"""Make a default factory for allow_* flags."""

def default_allow_flag(campaign: Campaign) -> bool | UnspecifiedType:
"""Attrs-compatible default factory for allow_* flags."""
if campaign.searchspace.type is SearchSpaceType.DISCRETE:
return default
return UNSPECIFIED

return default_allow_flag


def _validate_allow_flag(campaign: Campaign, attribute: Attribute, value: Any) -> None:
"""Attrs-compatible validator for context-aware validation of allow_* flags."""
match campaign.searchspace.type:
case SearchSpaceType.DISCRETE:
if not isinstance(value, bool):
raise ValueError(
f"For search spaces of '{SearchSpaceType.DISCRETE}', "
f"'{attribute.name}' must be a Boolean."
)
case _:
if value is not UNSPECIFIED:
raise ValueError(
f"For search spaces of type other than "
f"'{SearchSpaceType.DISCRETE}', '{attribute.name}' cannot be set "
f"since the flag is meaningless in such contexts.",
)


@define
class Campaign(SerialMixin):
"""Main class for interaction with BayBE.
Expand Down Expand Up @@ -88,6 +120,36 @@ class Campaign(SerialMixin):
)
"""The employed recommender"""

allow_recommending_already_measured: bool | UnspecifiedType = field(
default=Factory(
_make_allow_flag_default_factory(default=True), takes_self=True
),
validator=_validate_allow_flag,
kw_only=True,
)
"""Allow to recommend experiments that were already measured earlier.
Can only be set for discrete search spaces."""

allow_recommending_already_recommended: bool | UnspecifiedType = field(
default=Factory(
_make_allow_flag_default_factory(default=False), takes_self=True
),
validator=_validate_allow_flag,
kw_only=True,
)
"""Allow to recommend experiments that were already recommended earlier.
Can only be set for discrete search spaces."""

allow_recommending_pending_experiments: bool | UnspecifiedType = field(
default=Factory(
_make_allow_flag_default_factory(default=False), takes_self=True
),
validator=_validate_allow_flag,
kw_only=True,
)
"""Allow pending experiments to be part of the recommendations.
Can only be set for discrete search spaces."""

# Metadata
_searchspace_metadata: pd.DataFrame = field(init=False, eq=eq_dataframe)
"""Metadata tracking the experimentation status of the search space."""
Expand Down Expand Up @@ -371,21 +433,71 @@ def recommend(
self._measurements_exp.fillna({"FitNr": self.n_fits_done}, inplace=True)

# Prepare the search space according to the current campaign state
annotated_searchspace = evolve(
self.searchspace,
discrete=AnnotatedSubspaceDiscrete.from_subspace(
self.searchspace.discrete, self._searchspace_metadata
),
)
if self.searchspace.type is SearchSpaceType.DISCRETE:
# TODO: This implementation should at some point be hidden behind an
# appropriate public interface, like `SubspaceDiscrete.filter()`
mask_todrop = self._searchspace_metadata[_EXCLUDED].copy()
if not self.allow_recommending_already_recommended:
mask_todrop |= self._searchspace_metadata[_RECOMMENDED]
if not self.allow_recommending_already_measured:
mask_todrop |= self._searchspace_metadata[_MEASURED]
if (
not self.allow_recommending_pending_experiments
and pending_experiments is not None
):
mask_todrop |= pd.merge(
self.searchspace.discrete.exp_rep,
pending_experiments,
indicator=True,
how="left",
)["_merge"].eq("both")
searchspace = evolve(
AdrianSosic marked this conversation as resolved.
Show resolved Hide resolved
self.searchspace,
discrete=FilteredSubspaceDiscrete.from_subspace(
self.searchspace.discrete, ~mask_todrop.to_numpy()
),
)
else:
searchspace = self.searchspace

# Get the recommended search space entries
rec = self.recommender.recommend(
batch_size,
annotated_searchspace,
self.objective,
self._measurements_exp,
pending_experiments,
)
try:
rec = self.recommender.recommend(
batch_size,
searchspace,
self.objective,
self._measurements_exp,
pending_experiments,
)
except NotEnoughPointsLeftError as ex:
# Aliases for code compactness
f = fields(Campaign)
ok_m = self.allow_recommending_already_measured
ok_r = self.allow_recommending_already_recommended
ok_p = self.allow_recommending_pending_experiments
ok_m_name = f.allow_recommending_already_measured.name
ok_r_name = f.allow_recommending_already_recommended.name
ok_p_name = f.allow_recommending_pending_experiments.name
no_blocked_pending_points = ok_p or (pending_experiments is None)

# If there are no candidate restrictions to be relaxed
if ok_m and ok_r and no_blocked_pending_points:
raise ex

# Otherwise, extract possible relaxations
solution = [
f"'{name}=True'"
for name, value in [
(ok_m_name, ok_m),
(ok_r_name, ok_r),
(ok_p_name, no_blocked_pending_points),
]
if not value
]
message = solution[0] if len(solution) == 1 else " and/or ".join(solution)
raise NotEnoughPointsLeftError(
f"{str(ex)} Consider setting {message}."
) from ex

# Cache the recommendations
self._cached_recommendation = rec.copy()
Expand Down
5 changes: 5 additions & 0 deletions baybe/recommenders/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ def recommend(
_surrogate_model=cattrs.override(rename="surrogate_model"),
_current_recommender=cattrs.override(omit=False),
_used_recommender_ids=cattrs.override(omit=False),
_deprecated_allow_repeated_recommendations=cattrs.override(omit=True),
_deprecated_allow_recommending_already_measured=cattrs.override(omit=True),
_deprecated_allow_recommending_pending_experiments=cattrs.override(
omit=True
),
),
),
)
Expand Down
13 changes: 1 addition & 12 deletions baybe/recommenders/meta/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from abc import ABC, abstractmethod
from typing import Any

import cattrs
import pandas as pd
from attrs import define, field
from typing_extensions import override
Expand Down Expand Up @@ -135,17 +134,7 @@ def recommend(


# Register (un-)structure hooks
converter.register_unstructure_hook(
MetaRecommender,
lambda x: unstructure_base(
x,
# TODO: Remove once deprecation got expired:
overrides=dict(
allow_repeated_recommendations=cattrs.override(omit=True),
allow_recommending_already_measured=cattrs.override(omit=True),
),
),
)
converter.register_unstructure_hook(MetaRecommender, unstructure_base)
converter.register_structure_hook(
MetaRecommender, get_base_structure_hook(MetaRecommender)
)
Expand Down
41 changes: 3 additions & 38 deletions baybe/recommenders/naive.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
"""Naive recommender for hybrid spaces."""

import gc
import warnings
from typing import ClassVar

import pandas as pd
from attrs import define, evolve, field, fields
from attrs import define, field
from typing_extensions import override

from baybe.objectives.base import Objective
Expand Down Expand Up @@ -48,36 +47,6 @@ class NaiveHybridSpaceRecommender(PureRecommender):
"""The recommender used for the continuous subspace. Default:
:class:`baybe.recommenders.pure.bayesian.botorch.BotorchRecommender`"""

def __attrs_post_init__(self):
"""Validate if flags are synchronized and overrides them otherwise."""
if (
flag := self.allow_recommending_already_measured
) != self.disc_recommender.allow_recommending_already_measured:
warnings.warn(
f"The value of "
f"'{fields(self.__class__).allow_recommending_already_measured.name}' "
f"differs from what is specified in the discrete recommender. "
f"The value of the discrete recommender will be ignored."
)
self.disc_recommender = evolve(
self.disc_recommender,
allow_recommending_already_measured=flag,
)

if (
flag := self.allow_repeated_recommendations
) != self.disc_recommender.allow_repeated_recommendations:
warnings.warn(
f"The value of "
f"'{fields(self.__class__).allow_repeated_recommendations.name}' "
f"differs from what is specified in the discrete recommender. "
f"The value of the discrete recommender will be ignored."
)
self.disc_recommender = evolve(
self.disc_recommender,
allow_repeated_recommendations=flag,
)

@override
def recommend(
self,
Expand Down Expand Up @@ -122,12 +91,8 @@ def recommend(
cont_part = searchspace.continuous.sample_uniform(1)
cont_part_tensor = to_tensor(cont_part).unsqueeze(-2)

# Get discrete candidates. The metadata flags are ignored since the search space
# is hybrid
candidates_exp, _ = searchspace.discrete.get_candidates(
allow_repeated_recommendations=True,
allow_recommending_already_measured=True,
)
# Get discrete candidates
candidates_exp, _ = searchspace.discrete.get_candidates()

# We now check whether the discrete recommender is bayesian.
if isinstance(self.disc_recommender, BayesianRecommender):
Expand Down
Loading