diff --git a/bofire/data_models/acquisition_functions/acquisition_function.py b/bofire/data_models/acquisition_functions/acquisition_function.py index ab51b46bc..5304dcaf1 100644 --- a/bofire/data_models/acquisition_functions/acquisition_function.py +++ b/bofire/data_models/acquisition_functions/acquisition_function.py @@ -1,4 +1,4 @@ -from typing import Annotated, Dict, Literal, Optional +from typing import Annotated, Any, Dict, Literal, Optional from pydantic import Field, PositiveFloat @@ -7,15 +7,15 @@ class AcquisitionFunction(BaseModel): - type: str + type: Any class SingleObjectiveAcquisitionFunction(AcquisitionFunction): - type: str + type: Any class MultiObjectiveAcquisitionFunction(AcquisitionFunction): - type: str + type: Any class qNEI(SingleObjectiveAcquisitionFunction): @@ -87,3 +87,22 @@ class qNegIntPosVar(SingleObjectiveAcquisitionFunction): type: Literal["qNegIntPosVar"] = "qNegIntPosVar" n_mc_samples: IntPowerOfTwo = 512 weights: Optional[Dict[str, PositiveFloat]] = Field(default_factory=lambda: None) + + +class qLogPF(SingleObjectiveAcquisitionFunction): + """MC based batch LogProbability of Feasibility acquisition function. + + It is used to select the next batch of experiments to maximize the + probability of finding feasible solutions with respect to output + constraints in the next batch. It can be only used in the SoboStrategy + and is especially useful in combination with the FeasibleExperimentCondition + within the StepwiseStrategy. + + Attributes: + n_mc_samples: Number of Monte Carlo samples to use to + approximate the probability of feasibility. + + """ + + type: Literal["qLogPF"] = "qLogPF" + n_mc_samples: IntPowerOfTwo = 512 diff --git a/bofire/data_models/acquisition_functions/api.py b/bofire/data_models/acquisition_functions/api.py index edc81fa86..d7cf9184b 100644 --- a/bofire/data_models/acquisition_functions/api.py +++ b/bofire/data_models/acquisition_functions/api.py @@ -10,6 +10,7 @@ qLogEI, qLogNEHVI, qLogNEI, + qLogPF, qNegIntPosVar, qNEHVI, qNEI, @@ -38,16 +39,11 @@ qNEHVI, qLogNEHVI, qNegIntPosVar, + qLogPF, ] AnySingleObjectiveAcquisitionFunction = Union[ - qNEI, - qEI, - qSR, - qUCB, - qPI, - qLogEI, - qLogNEI, + qNEI, qEI, qSR, qUCB, qPI, qLogEI, qLogNEI, qLogPF ] AnyMultiObjectiveAcquisitionFunction = Union[qEHVI, qLogEHVI, qNEHVI, qLogNEHVI] diff --git a/bofire/data_models/strategies/api.py b/bofire/data_models/strategies/api.py index 428658f5b..3d96adc7f 100644 --- a/bofire/data_models/strategies/api.py +++ b/bofire/data_models/strategies/api.py @@ -58,6 +58,7 @@ AlwaysTrueCondition, AnyCondition, CombiCondition, + FeasibleExperimentCondition, NumberOfExperimentsCondition, ) from bofire.data_models.strategies.stepwise.stepwise import Step, StepwiseStrategy diff --git a/bofire/data_models/strategies/predictives/multi_fidelity.py b/bofire/data_models/strategies/predictives/multi_fidelity.py index c796594b0..e7b2ca07c 100644 --- a/bofire/data_models/strategies/predictives/multi_fidelity.py +++ b/bofire/data_models/strategies/predictives/multi_fidelity.py @@ -4,11 +4,11 @@ from bofire.data_models.domain.api import Domain, Outputs from bofire.data_models.features.api import TaskInput -from bofire.data_models.strategies.predictives.sobo import SoboStrategy +from bofire.data_models.strategies.predictives.sobo import SoboStrategy, _ForbidPFMixin from bofire.data_models.surrogates.api import BotorchSurrogates, MultiTaskGPSurrogate -class MultiFidelityStrategy(SoboStrategy): +class MultiFidelityStrategy(SoboStrategy, _ForbidPFMixin): type: Literal["MultiFidelityStrategy"] = "MultiFidelityStrategy" fidelity_thresholds: Union[List[float], float] = 0.1 diff --git a/bofire/data_models/strategies/predictives/sobo.py b/bofire/data_models/strategies/predictives/sobo.py index 6410ab694..6acc83d7a 100644 --- a/bofire/data_models/strategies/predictives/sobo.py +++ b/bofire/data_models/strategies/predictives/sobo.py @@ -6,6 +6,7 @@ from bofire.data_models.acquisition_functions.api import ( AnySingleObjectiveAcquisitionFunction, qLogNEI, + qLogPF, ) from bofire.data_models.features.api import Feature from bofire.data_models.objectives.api import ConstrainedObjective, Objective @@ -47,23 +48,43 @@ def is_objective_implemented(cls, my_type: Type[Objective]) -> bool: class SoboStrategy(SoboBaseStrategy): type: Literal["SoboStrategy"] = "SoboStrategy" - @field_validator("domain") - @classmethod - def validate_is_singleobjective(cls, v, values): - if len(v.outputs) == 1: - return v + @model_validator(mode="after") + def validate_is_singleobjective(self): if ( - len(v.outputs.get_by_objective(excludes=ConstrainedObjective)) - - len(v.outputs.get_by_objective(includes=None, excludes=Objective)) - ) > 1: + len(self.domain.outputs.get_by_objective(excludes=ConstrainedObjective)) + - len( + self.domain.outputs.get_by_objective(includes=None, excludes=Objective) # type: ignore + ) + ) > 1 and not isinstance(self.acquisition_function, qLogPF): raise ValueError( "SOBO strategy can only deal with one no-constraint objective.", ) - return v + if isinstance(self.acquisition_function, qLogPF): + if len(self.domain.outputs.get_by_objective(ConstrainedObjective)) == 0: + raise ValueError( + "At least one constrained objective is required for qLogPF.", + ) + return self + +class _ForbidPFMixin: + """ + Mixin to forbid the use of qLogPF acquisition function in single-objective strategies + that are not the SoboStrategy. + """ + + @field_validator("acquisition_function") + def validate_acquisition_function(cls, acquisition_function): + if isinstance(acquisition_function, qLogPF): + raise ValueError( + "qLogPF acquisition function is only allowed in the ´SoboStrategy´.", + ) + return acquisition_function -class AdditiveSoboStrategy(SoboBaseStrategy): + +class AdditiveSoboStrategy(SoboBaseStrategy, _ForbidPFMixin): type: Literal["AdditiveSoboStrategy"] = "AdditiveSoboStrategy" + use_output_constraints: bool = True @field_validator("domain") @@ -93,7 +114,9 @@ def check_adaptable_weights(cls, self): return self -class MultiplicativeSoboStrategy(SoboBaseStrategy, _CheckAdaptableWeightsMixin): +class MultiplicativeSoboStrategy( + SoboBaseStrategy, _CheckAdaptableWeightsMixin, _ForbidPFMixin +): type: Literal["MultiplicativeSoboStrategy"] = "MultiplicativeSoboStrategy" @field_validator("domain") @@ -105,7 +128,9 @@ def validate_is_multiobjective(cls, v, info): return v -class MultiplicativeAdditiveSoboStrategy(SoboBaseStrategy, _CheckAdaptableWeightsMixin): +class MultiplicativeAdditiveSoboStrategy( + SoboBaseStrategy, _CheckAdaptableWeightsMixin, _ForbidPFMixin +): """ Mixed, weighted multiplicative (primary, strict) and additive (secondary, non-strict) objectives. @@ -121,6 +146,7 @@ class MultiplicativeAdditiveSoboStrategy(SoboBaseStrategy, _CheckAdaptableWeight type: Literal["MultiplicativeAdditiveSoboStrategy"] = ( "MultiplicativeAdditiveSoboStrategy" ) + use_output_constraints: bool = True additive_features: List[str] = Field(default_factory=list) @@ -135,7 +161,8 @@ def validate_additive_features(cls, v, values): return v -class CustomSoboStrategy(SoboBaseStrategy): +class CustomSoboStrategy(SoboBaseStrategy, _ForbidPFMixin): type: Literal["CustomSoboStrategy"] = "CustomSoboStrategy" + use_output_constraints: bool = True dump: Optional[str] = None diff --git a/bofire/data_models/strategies/stepwise/conditions.py b/bofire/data_models/strategies/stepwise/conditions.py index 7212f44f6..cba3d4eab 100644 --- a/bofire/data_models/strategies/stepwise/conditions.py +++ b/bofire/data_models/strategies/stepwise/conditions.py @@ -1,11 +1,13 @@ from abc import abstractmethod -from typing import Annotated, List, Literal, Optional, Union +from typing import Annotated, Any, List, Literal, Optional, Union import pandas as pd -from pydantic import Field, field_validator +from pydantic import Field, PositiveInt, field_validator from bofire.data_models.base import BaseModel +from bofire.data_models.constraints.api import IntrapointConstraint from bofire.data_models.domain.api import Domain +from bofire.data_models.objectives.api import ConstrainedObjective class EvaluateableCondition: @@ -15,11 +17,68 @@ def evaluate(self, domain: Domain, experiments: Optional[pd.DataFrame]) -> bool: class Condition(BaseModel): - type: str + type: Any class SingleCondition(BaseModel): - type: str + type: Any + + +class FeasibleExperimentCondition(SingleCondition, EvaluateableCondition): + """Condition to check if a certain number of feasible experiments are available. + + For this purpose, the condition checks if there are any kind of ConstrainedObjective's + in the domain. If, yes it checks if there is a certain number of feasible experiments. + The condition is fulfilled if the number of feasible experiments is smaller than + the number of required feasible experiments. It is not fulfilled when there are no + ConstrainedObjective's in the domain. + This condition can be used in scenarios where there is a large amount of output constraints + and one wants to make sure that they are fulfilled before optimizing the actual objective(s). + To do this, it is best to combine this condition with the SoboStrategy and qLogPF + as acquisition function. + + Attributes: + n_required_feasible_experiments: Number of required feasible experiments. + threshold: Threshold for the feasibility calculation. Default is 0.9. + """ + + type: Literal["FeasibleExperimentCondition"] = "FeasibleExperimentCondition" + n_required_feasible_experiments: PositiveInt = 1 + threshold: Annotated[float, Field(ge=0, le=1)] = 0.9 + + def evaluate(self, domain: Domain, experiments: Optional[pd.DataFrame]) -> bool: + constrained_outputs = domain.outputs.get_by_objective(ConstrainedObjective) + if len(constrained_outputs) == 0: + return False + + if experiments is None: + return True + + valid_experiments = ( + constrained_outputs.preprocess_experiments_all_valid_outputs(experiments) + ) + relevant_constraints = domain.constraints.get(IntrapointConstraint) + if len(relevant_constraints) > 0: + valid_experiments = valid_experiments[ + relevant_constraints.is_fulfilled(valid_experiments) + ] + + # TODO: have a is fulfilled for input features --> work for future PR + feasibilities = pd.concat( + [ + feat( + valid_experiments[feat.key], + valid_experiments[feat.key], # type: ignore + ) + for feat in constrained_outputs + ], + axis=1, + ).product(axis=1) + + return bool( + feasibilities[feasibilities >= self.threshold].sum() + < self.n_required_feasible_experiments + ) class NumberOfExperimentsCondition(SingleCondition, EvaluateableCondition): @@ -72,4 +131,9 @@ def evaluate(self, domain: Domain, experiments: Optional[pd.DataFrame]) -> bool: return False -AnyCondition = Union[NumberOfExperimentsCondition, CombiCondition, AlwaysTrueCondition] +AnyCondition = Union[ + NumberOfExperimentsCondition, + CombiCondition, + AlwaysTrueCondition, + FeasibleExperimentCondition, +] diff --git a/bofire/strategies/predictives/sobo.py b/bofire/strategies/predictives/sobo.py index 715645563..c4141aeff 100644 --- a/bofire/strategies/predictives/sobo.py +++ b/bofire/strategies/predictives/sobo.py @@ -22,12 +22,17 @@ import torch from botorch.acquisition import get_acquisition_function from botorch.acquisition.acquisition import AcquisitionFunction -from botorch.acquisition.objective import ConstrainedMCObjective, GenericMCObjective +from botorch.acquisition.objective import ( + ConstrainedMCObjective, + GenericMCObjective, + IdentityMCObjective, +) from botorch.models.gpytorch import GPyTorchModel from bofire.data_models.acquisition_functions.api import ( AnySingleObjectiveAcquisitionFunction, qLogNEI, + qLogPF, qNEI, qPI, qSR, @@ -111,7 +116,7 @@ def _get_acqfs(self, n) -> List[AcquisitionFunction]: def _get_objective_and_constraints( self, ) -> Tuple[ - Union[GenericMCObjective, ConstrainedMCObjective], + Union[GenericMCObjective, ConstrainedMCObjective, IdentityMCObjective], Union[List[Callable[[torch.Tensor], torch.Tensor]], None], Union[List, float], ]: @@ -165,7 +170,9 @@ def _get_objective_and_constraints( # return regular objective return ( - GenericMCObjective(objective=objective_callable), + GenericMCObjective(objective=objective_callable) + if not isinstance(self.acquisition_function, qLogPF) + else IdentityMCObjective(), constraint_callables, etas, ) @@ -174,7 +181,9 @@ def _get_objective_and_constraints( def make( cls, domain: Domain, - acquisition_function: AnySingleObjectiveAcquisitionFunction | None = None, + acquisition_function: AnySingleObjectiveAcquisitionFunction + | qLogPF + | None = None, acquisition_optimizer: AnyAcqfOptimizer | None = None, surrogate_specs: BotorchSurrogates | None = None, outlier_detection_specs: OutlierDetections | None = None, diff --git a/bofire/utils/torch_tools.py b/bofire/utils/torch_tools.py index 296e526e3..2f91406f9 100644 --- a/bofire/utils/torch_tools.py +++ b/bofire/utils/torch_tools.py @@ -5,10 +5,12 @@ import pandas as pd import torch from botorch.models.transforms.input import InputTransform +from botorch.utils.datasets import SupervisedDataset +from botorch.utils.objective import compute_smoothed_feasibility_indicator from torch import Tensor from torch.nn import Module -from bofire.data_models.api import AnyObjective, Domain, Outputs +from bofire.data_models.api import AnyObjective, Domain, Inputs, Outputs from bofire.data_models.constraints.api import ( Constraint, InterpointEqualityConstraint, @@ -34,6 +36,7 @@ PeakDesirabilityObjective, TargetObjective, ) +from bofire.data_models.types import InputTransformSpecs from bofire.strategies.strategy import Strategy @@ -401,6 +404,38 @@ def get_output_constraints( return constraints, etas +def get_number_of_feasible_solutions( + predictions: Tensor, + constraints: List[Callable[[Tensor], Tensor]], + etas: List[float], + threshold=0.9, +): + """Computes the number of feasible candidates based on the constraints and etas + and a feasibility threshold. + + Args: + predictions: Tensor containing the predictions for which to compute the feasibility. + constraints: List of constraint callables. + etas: List of eta values for the constraints. + X: The samples for which to compute the feasibility. + threshold: The threshold for feasibility. + + Returns: + Tensor: A tensor indicating the feasibility of each sample. + + """ + + feasibilities = compute_smoothed_feasibility_indicator( + constraints=constraints, + samples=predictions, + eta=torch.tensor(etas).to(**tkwargs), + log=False, + fat=False, # TODO: add this to _get_objective_and_constraints + ) + count = torch.sum(feasibilities >= threshold).item() + return count + + def get_objective_callable( idx: int, objective: AnyObjective, @@ -1007,3 +1042,38 @@ def transform(self, X: Tensor): return torch.cat([new_y, X], dim=-1) return new_y + + +def create_supervised_dataset( + inputs: Inputs, + outputs: Outputs, + experiments: pd.DataFrame, + input_preprocessing_specs: InputTransformSpecs, + drop_duplicates: bool = True, +) -> SupervisedDataset: + filtered_experiments = outputs.preprocess_experiments_all_valid_outputs( + experiments, + ) + + if drop_duplicates: + filtered_experiments = filtered_experiments.drop_duplicates( + subset=[var.key for var in inputs.get()], + keep="first", + inplace=False, + ) + + transformed = inputs.transform( + filtered_experiments, + input_preprocessing_specs, + ) + X = torch.from_numpy(transformed.values).to(**tkwargs) + # Todo: catch it for categoricals + Y = torch.from_numpy(filtered_experiments[outputs.get_keys()]).to(**tkwargs) + + return SupervisedDataset( + X=X, + Y=Y, + feature_names=transformed.columns.to_list(), + outcome_names=outputs.get_keys(), + validate_init=True, + ) diff --git a/docs/strategies.md b/docs/strategies.md new file mode 100644 index 000000000..8e01782f9 --- /dev/null +++ b/docs/strategies.md @@ -0,0 +1,118 @@ +# Strategies + +Strategies are the key ingredient of BoFire that explore the search space defined in the `Domain` and provide candidates for the next experiment or batch of experiments. Available strategies can be clustered in the following subclasses. + +## Non-predictive Strategies + +BoFire offers the following strategies for sampling from the search space (no output features need to be provided in the `Domain`): + +- `RandomStrategy`: This strategy proposes candidates by (quasi-)random sampling from the search space. It is applicable to almost all combinations of input features and constraints. + +- `DoEStrategy`: This strategy offers model-based DoE approaches for proposing candidates. + +- `FractionalFactorialStrategy`: This strategy should be used to generate (fractional-)factorial designs. + +## Predictive Strategies + +Predictive strategies are making use of (Bayesian) surrogate models to provide candidates with the intention to achieve certain goals depending on the provided objectives of the output features. + +The following predictive strategies are available: + +- `SoboStrategy`: Bayesian optimization strategy that optimizes a single-objective acquisition function. For multi-objective domains, different scalarizations are possible as implemented in the `AdditiveSoboStrategy`, `MultiplicativeSoboStrategy`, `AdditiveMultiplicativeSoboStrategy` and `CustomSoboStrategy`. +- `MoboStrategy`: Bayesian optimization strategy that optimizes a hypervolume based acquisition function for pareto-based multi-objective optimization. +- `QparegoStrategy`: Parallel ParEGO strategy for multiobjective optimization. +- `MultifidelityStrategy:` Single objective multi-fidelity BO as described [here](https://www.sciencedirect.com/science/article/pii/S0098135423000637) +- `EntingStrategy`: Strategy based on the `Entmoot` [package](https://github.com/cog-imperial/entmoot) that uses tree-based surrogate models to perform both single-objective and multiobjective optimization. + +## Combining Strategies + +In BoFire, the `StepwiseStrategy` operates on a sequence of strategies and determines when to switch between them based on customizable logical operators. + +The `StepwiseStrategy` is comprised of a sequence of `Step`s, where each `Step` consists of the following three attributes: + +- `strategy_data`: data model of the strategy which should be executed in this step. +- `condition`: A logical expression that determines when this `step`'s strategy should be executed. The `StepwiseStrategy` evaluates each step in order and selects the first strategy whose condition evaluates to `True`. +- `transform`: An object that can be used to transform experiments and/or candidates before they enter/leave the strategy assigned in the step. + +The following example demonstrates how to combine a `RandomStrategy` with a `SoboStrategy` using the `StepwiseStrategy`. In this setup, the `RandomStrategy` is applied initially to propose candidates until 10 experiments have been completed. Once this threshold is reached, the strategy automatically switches to the `SoboStrategy` for subsequent candidate generation. + +``` python + +import bofire.strategies.api as strategies +from bofire.benchmarks.api import Himmelblau +from bofire.data_models.strategies.api import ( + AlwaysTrueCondition, + NumberOfExperimentsCondition, + RandomStrategy, + SoboStrategy, + Step, + StepwiseStrategy, +) + + +domain = Himmelblau().domain + +strategy_data = StepwiseStrategy( + domain=domain, + steps=[ + Step( + strategy_data=RandomStrategy(domain=domain), + condition=NumberOfExperimentsCondition(n_experiments=10), + ), + Step( + strategy_data=SoboStrategy(domain=domain), condition=AlwaysTrueCondition() + ), + ], +) + +strategy = strategies.map(strategy_data) +``` + +When dealing with output constraints, it is often beneficial to ensure that a certain number of experiments satisfy these constraints before proceeding with the main optimization. If no feasible experiments have been found, the strategy should prioritize generating candidates likely to fulfill the output constraints. This can be accomplished by using the `qLogPF` (Probability of Feasibility) acquisition function together with the `FeasibleExperimentCondition`. This approach allows the optimization process to focus first on feasibility, and only switch to the main objective once enough feasible experiments are available. + +In the following example, a `RandomStrategy` is applied for the initial 10 experiments to broadly explore the search space. Afterward, the `SoboStrategy` with the `qLogPF` acquisition function is used to prioritize finding at least one feasible experiment that satisfies the output constraints. Once this feasibility criterion is met, the strategy transitions to the standard `SoboStrategy` to focus on optimizing the main objective. + +```python +import bofire.strategies.api as strategies +from bofire.benchmarks.api import DTLZ2 +from bofire.data_models.acquisition_functions.api import qLogPF +from bofire.data_models.objectives.api import MaximizeSigmoidObjective +from bofire.data_models.strategies.api import ( + AlwaysTrueCondition, + FeasibleExperimentCondition, + NumberOfExperimentsCondition, + RandomStrategy, + SoboStrategy, + Step, + StepwiseStrategy, +) + + +# create a domain with one output constraint by assigning a MaximizeSigmoidObjective +# to the output with key "f_1" +domain = DTLZ2(dim=6).domain +domain.outputs.get_by_key("f_1").objective = MaximizeSigmoidObjective( + tp=0.5, steepness=100 +) + + +strategy_data = StepwiseStrategy( + domain=domain, + steps=[ + Step( + strategy_data=RandomStrategy(domain=domain), + condition=NumberOfExperimentsCondition(n_experiments=10), + ), + Step( + strategy_data=SoboStrategy(domain=domain, acquisition_function=qLogPF()), + condition=FeasibleExperimentCondition(n_required_feasible_experiments=1), + ), + Step( + strategy_data=SoboStrategy(domain=domain), condition=AlwaysTrueCondition() + ), + ], +) + +strategy = strategies.map(strategy_data) + +``` diff --git a/mkdocs.yaml b/mkdocs.yaml index 8880f4cbf..c05c65503 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -8,6 +8,7 @@ nav: - Install: install.md - Notebook page: getting_started.ipynb - Examples: examples.md + - Strategies: strategies.md - Data Models vs Functional Components: data_models_functionals.md - Surrogate Models: userguide_surrogates.md - API Reference: diff --git a/pyproject.toml b/pyproject.toml index 3ae4b999f..c7a435ae8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ [project.optional-dependencies] optimization = [ - "botorch>=0.13.0", + "botorch>=0.14.0", "numpy", "multiprocess", "plotly", @@ -61,7 +61,7 @@ docs = [ ] tutorials = ["jupyter", "matplotlib", "seaborn"] all = [ - "botorch>=0.13.0", + "botorch>=0.14.0", "numpy", "multiprocess", "plotly", diff --git a/tests/bofire/data_models/specs/acquisition_functions.py b/tests/bofire/data_models/specs/acquisition_functions.py index 6748119e7..4f336e3da 100644 --- a/tests/bofire/data_models/specs/acquisition_functions.py +++ b/tests/bofire/data_models/specs/acquisition_functions.py @@ -6,6 +6,11 @@ specs = Specs([]) +specs.add_valid( + acquisition_functions.qLogPF, + lambda: {"n_mc_samples": 512}, +) + specs.add_valid( acquisition_functions.qEI, lambda: {"n_mc_samples": 512}, diff --git a/tests/bofire/data_models/specs/conditions.py b/tests/bofire/data_models/specs/conditions.py index feddf5c5e..60294226d 100644 --- a/tests/bofire/data_models/specs/conditions.py +++ b/tests/bofire/data_models/specs/conditions.py @@ -1,6 +1,7 @@ from bofire.data_models.strategies.api import ( AlwaysTrueCondition, CombiCondition, + FeasibleExperimentCondition, NumberOfExperimentsCondition, ) from tests.bofire.data_models.specs.specs import Specs @@ -28,3 +29,8 @@ "n_required_conditions": 2, }, ) + +specs.add_valid( + FeasibleExperimentCondition, + lambda: {"n_required_feasible_experiments": 3, "threshold": 0.95}, +) diff --git a/tests/bofire/data_models/specs/strategies.py b/tests/bofire/data_models/specs/strategies.py index 30bff86e3..1f972b581 100644 --- a/tests/bofire/data_models/specs/strategies.py +++ b/tests/bofire/data_models/specs/strategies.py @@ -3,6 +3,7 @@ from bofire.data_models.acquisition_functions.api import ( qEI, qLogNEHVI, + qLogPF, qNegIntPosVar, qPI, ) @@ -21,7 +22,10 @@ DiscreteInput, TaskInput, ) -from bofire.data_models.objectives.api import MaximizeObjective +from bofire.data_models.objectives.api import ( + MaximizeObjective, + MaximizeSigmoidObjective, +) from bofire.data_models.strategies.api import ( AbsoluteMovingReferenceValue, ExplicitReferencePoint, @@ -619,6 +623,63 @@ message="LSR-BO only supported for linear constraints.", ) +specs.add_invalid( + strategies.SoboStrategy, + lambda: { + "domain": Domain( + inputs=Inputs( + features=[ + ContinuousInput(key=k, bounds=(0, 1)) for k in ["a", "b", "c"] + ] + ), + outputs=[ContinuousOutput(key="alpha", objective=MaximizeObjective())], + ), + "acquisition_function": qLogPF(), + }, + error=ValueError, + message="At least one constrained objective is required for qLogPF.", +) + +specs.add_invalid( + strategies.AdditiveSoboStrategy, + lambda: { + "domain": Domain( + inputs=Inputs( + features=[ + ContinuousInput(key=k, bounds=(0, 1)) for k in ["a", "b", "c"] + ] + ), + outputs=[ + ContinuousOutput( + key="alpha", objective=MaximizeSigmoidObjective(tp=1, steepness=100) + ), + ContinuousOutput(key="beta", objective=MaximizeObjective()), + ], + ), + "acquisition_function": qLogPF(), + }, + error=ValueError, + message="qLogPF acquisition function is only allowed in the ´SoboStrategy´.", +) + +specs.add_invalid( + strategies.SoboStrategy, + lambda: { + "domain": Domain( + inputs=Inputs( + features=[ + ContinuousInput(key=k, bounds=(0, 1)) for k in ["a", "b", "c"] + ] + ), + outputs=[ + ContinuousOutput(key="alpha", objective=MaximizeObjective()), + ContinuousOutput(key="beta", objective=MaximizeObjective()), + ], + ), + }, + error=ValueError, + message="SOBO strategy can only deal with one no-constraint objective.", +) specs.add_invalid( strategies.SoboStrategy, lambda: { diff --git a/tests/bofire/strategies/stepwise/test_conditions.py b/tests/bofire/strategies/stepwise/test_conditions.py index fcc7b10ef..2225e9b26 100644 --- a/tests/bofire/strategies/stepwise/test_conditions.py +++ b/tests/bofire/strategies/stepwise/test_conditions.py @@ -1,7 +1,76 @@ +import pandas as pd import pytest import bofire.data_models.strategies.api as data_models from bofire.benchmarks.single import Himmelblau +from bofire.data_models.constraints.api import LinearEqualityConstraint +from bofire.data_models.domain.api import Constraints, Domain +from bofire.data_models.features.api import ContinuousInput, ContinuousOutput +from bofire.data_models.objectives.api import ( + MaximizeObjective, + MaximizeSigmoidObjective, +) + + +def test_FeasibleExperimentCondition(): + domain = Domain( + inputs=[ + ContinuousInput(key="x1", bounds=[0, 1]), + ContinuousInput(key="x2", bounds=[0, 1]), + ], + outputs=[ + ContinuousOutput(key="of1", objective=MaximizeObjective()), + ContinuousOutput( + key="of2", objective=MaximizeSigmoidObjective(tp=5, steepness=1000) + ), + ContinuousOutput( + key="of3", objective=MaximizeSigmoidObjective(tp=10, steepness=1000) + ), + ], + ) + + experiments = pd.DataFrame( + { + "x1": [0.1, 0.4, 0.7], + "x2": [10, -2, 10], + "of1": [0.1, 0.4, 0.7], + "of2": [6, -2, 10], + "of3": [11, 5.1, 0], + "valid_of1": [True, True, True], + "valid_of2": [True, True, True], + "valid_of3": [True, True, True], + } + ) + + condition = data_models.FeasibleExperimentCondition( + n_required_feasible_experiments=3, + threshold=0.9, + ) + assert condition.evaluate(domain, experiments=experiments) is True + condition.n_required_feasible_experiments = 2 + assert condition.evaluate(domain, experiments=experiments) is True + assert condition.evaluate(domain, experiments=None) is True + condition.n_required_feasible_experiments = 1 + assert condition.evaluate(domain, experiments=experiments) is False + + domain.constraints = Constraints( + constraints=[ + LinearEqualityConstraint( + features=["x1", "x2"], + coefficients=[ + 1.0, + 1.0, + ], + rhs=200, + ) + ] + ) + assert condition.evaluate(domain, experiments=experiments) is True + + for feat in domain.outputs.get(): + feat.objective = MaximizeObjective() + condition.n_required_feasible_experiments = 3 + assert condition.evaluate(domain, experiments=experiments) is False def test_RequiredExperimentsCondition(): diff --git a/tests/bofire/strategies/test_sobo.py b/tests/bofire/strategies/test_sobo.py index a852dbddc..c5228fe11 100644 --- a/tests/bofire/strategies/test_sobo.py +++ b/tests/bofire/strategies/test_sobo.py @@ -14,7 +14,8 @@ qSimpleRegret, qUpperConfidenceBound, ) -from botorch.acquisition.objective import ConstrainedMCObjective, GenericMCObjective +from botorch.acquisition.logei import qLogProbabilityOfFeasibility +from botorch.acquisition.objective import GenericMCObjective, IdentityMCObjective import bofire.data_models.strategies.api as data_models import tests.bofire.data_models.specs.api as specs @@ -26,6 +27,7 @@ qEI, qLogEI, qLogNEI, + qLogPF, qNEI, qPI, qSR, @@ -68,6 +70,7 @@ def test_SOBO_not_fitted(): (qSR(), qSimpleRegret), (qLogEI(), qLogExpectedImprovement), (qLogNEI(), qLogNoisyExpectedImprovement), + (qLogPF(), qLogProbabilityOfFeasibility), ], ) def test_SOBO_get_acqf(acqf, expected): @@ -80,6 +83,12 @@ def test_SOBO_get_acqf(acqf, expected): experiments = benchmark.f(random_strategy.ask(20), return_complete=True) + if isinstance(acqf, qLogPF): + benchmark.domain.outputs.features[0].objective = MaximizeSigmoidObjective( + tp=1.5, + steepness=2.0, + ) + data_model = data_models.SoboStrategy( domain=benchmark.domain, acquisition_function=acqf, @@ -293,12 +302,13 @@ def test_sobo_fully_combinatorial(candidate_count): @pytest.mark.parametrize( - "outputs, expected_objective", + "outputs, acqf, expected_objective,", [ ( Outputs( features=[ContinuousOutput(key="alpha", objective=MaximizeObjective())], ), + qEI(), GenericMCObjective, ), ( @@ -310,16 +320,30 @@ def test_sobo_fully_combinatorial(candidate_count): ), ], ), + qEI(), GenericMCObjective, ), + ( + Outputs( + features=[ + ContinuousOutput( + key="alpha", + objective=MaximizeSigmoidObjective(steepness=1, tp=1), + ), + ], + ), + qLogPF(), + IdentityMCObjective, + ), ], ) -def test_sobo_get_objective(outputs, expected_objective): +def test_sobo_get_objective(outputs, acqf, expected_objective): strategy_data = data_models.SoboStrategy( domain=Domain( inputs=Inputs(features=[ContinuousInput(key="a", bounds=(0, 1))]), outputs=outputs, ), + acquisition_function=acqf, ) experiments = pd.DataFrame({"a": [0.5], "alpha": [0.5], "valid_alpha": [1]}) strategy = SoboStrategy(data_model=strategy_data) @@ -328,21 +352,6 @@ def test_sobo_get_objective(outputs, expected_objective): assert isinstance(obj, expected_objective) -def test_sobo_get_constrained_objective(): - benchmark = DTLZ2(dim=6) - experiments = benchmark.f(benchmark.domain.inputs.sample(5), return_complete=True) - domain = benchmark.domain - domain.outputs.get_by_key("f_1").objective = MaximizeSigmoidObjective( # type: ignore - tp=1.5, - steepness=2.0, - ) - strategy_data = data_models.SoboStrategy(domain=domain, acquisition_function=qUCB()) - strategy = SoboStrategy(data_model=strategy_data) - strategy.tell(experiments=experiments) - obj, _, _ = strategy._get_objective_and_constraints() - assert isinstance(obj, ConstrainedMCObjective) - - def test_sobo_get_constrained_objective2(): benchmark = DTLZ2(dim=6) experiments = benchmark.f(benchmark.domain.inputs.sample(5), return_complete=True)