Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
27 changes: 23 additions & 4 deletions bofire/data_models/acquisition_functions/acquisition_function.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Annotated, Dict, Literal, Optional
from typing import Annotated, Any, Dict, Literal, Optional

from pydantic import Field, PositiveFloat

Expand All @@ -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):
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there a literature reference that you can add?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No, this is based on the BoFire PR, in which they implemented it: meta-pytorch/botorch#2815

@bertiqwerty bertiqwerty May 30, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would it make sense to add a link to this paper mentioned in the BoTorch API reference and a link to the BoTorch API reference itself?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The paper does not really fit, it does not mention the new acqf and is just explaining why one should use log based acqfs ;)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Now I am confused. Ok, it is not the main topic of the paper. But at least there is this section.
grafik

Or is this PR about something else and I missed it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is the formula for constrained expected improvement, which we were using automatically when we use EI in combination with output constraints. This PR introduced the qLogPF (PF = Probabiliy of feasibility) acquisition function which is just the feasibility term without EI.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Aaaah. Got it. Now your new documentation (strategies.md) makes sense to me :D.

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
2 changes: 2 additions & 0 deletions bofire/data_models/acquisition_functions/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
qLogEI,
qLogNEHVI,
qLogNEI,
qLogPF,
qNegIntPosVar,
qNEHVI,
qNEI,
Expand Down Expand Up @@ -38,6 +39,7 @@
qNEHVI,
qLogNEHVI,
qNegIntPosVar,
qLogPF,
]

AnySingleObjectiveAcquisitionFunction = Union[

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

didn't you say above this was a single objective acquisition function? shouldn't it appear here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It is kind of a special case of a single objective acqf, as it should be only used for SoboStrategy but not for the other SoboStrategys like AdditiveSoboStrategy etc. This is why I implemented it in this way. Do you have a better idea? I know, it is not ideal ...

Expand Down
1 change: 1 addition & 0 deletions bofire/data_models/strategies/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
AlwaysTrueCondition,
AnyCondition,
CombiCondition,
FeasibleExperimentCondition,
NumberOfExperimentsCondition,
)
from bofire.data_models.strategies.stepwise.stepwise import Step, StepwiseStrategy
Expand Down
10 changes: 9 additions & 1 deletion bofire/data_models/strategies/predictives/multi_fidelity.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from typing import List, Literal, Union

from pydantic import model_validator
from pydantic import Field, model_validator

from bofire.data_models.acquisition_functions.api import (
AnySingleObjectiveAcquisitionFunction,
qLogNEI,
)
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
Expand All @@ -11,6 +15,10 @@
class MultiFidelityStrategy(SoboStrategy):
type: Literal["MultiFidelityStrategy"] = "MultiFidelityStrategy"

acquisition_function: AnySingleObjectiveAcquisitionFunction = Field(
default_factory=lambda: qLogNEI(),
)

fidelity_thresholds: Union[List[float], float] = 0.1

@model_validator(mode="after")
Expand Down
46 changes: 32 additions & 14 deletions bofire/data_models/strategies/predictives/sobo.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
from typing import List, Literal, Optional, Type
from typing import List, Literal, Optional, Type, Union

import pydantic
from pydantic import Field, field_validator, model_validator

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
from bofire.data_models.strategies.predictives.botorch import BotorchStrategy


class SoboBaseStrategy(BotorchStrategy):
acquisition_function: AnySingleObjectiveAcquisitionFunction = Field(
default_factory=lambda: qLogNEI(),
)

@classmethod
def is_feature_implemented(cls, my_type: Type[Feature]) -> bool:
"""Method to check if a specific feature type is implemented for the strategy
Expand Down Expand Up @@ -47,23 +44,34 @@ 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
acquisition_function: Union[AnySingleObjectiveAcquisitionFunction, qLogPF] = Field(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

See above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

see my comment above.

default_factory=lambda: qLogNEI(),
)

@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 AdditiveSoboStrategy(SoboBaseStrategy):
type: Literal["AdditiveSoboStrategy"] = "AdditiveSoboStrategy"
acquisition_function: AnySingleObjectiveAcquisitionFunction = Field(
default_factory=lambda: qLogNEI(),
)
use_output_constraints: bool = True

@field_validator("domain")
Expand Down Expand Up @@ -96,6 +104,10 @@ def check_adaptable_weights(cls, self):
class MultiplicativeSoboStrategy(SoboBaseStrategy, _CheckAdaptableWeightsMixin):
type: Literal["MultiplicativeSoboStrategy"] = "MultiplicativeSoboStrategy"

acquisition_function: AnySingleObjectiveAcquisitionFunction = Field(
default_factory=lambda: qLogNEI(),
)

@field_validator("domain")
def validate_is_multiobjective(cls, v, info):
if (len(v.outputs.get_by_objective(Objective))) < 2:
Expand All @@ -121,6 +133,9 @@ class MultiplicativeAdditiveSoboStrategy(SoboBaseStrategy, _CheckAdaptableWeight
type: Literal["MultiplicativeAdditiveSoboStrategy"] = (
"MultiplicativeAdditiveSoboStrategy"
)
acquisition_function: AnySingleObjectiveAcquisitionFunction = Field(
default_factory=lambda: qLogNEI(),
)
use_output_constraints: bool = True
additive_features: List[str] = Field(default_factory=list)

Expand All @@ -137,5 +152,8 @@ def validate_additive_features(cls, v, values):

class CustomSoboStrategy(SoboBaseStrategy):
type: Literal["CustomSoboStrategy"] = "CustomSoboStrategy"
acquisition_function: AnySingleObjectiveAcquisitionFunction = Field(
default_factory=lambda: qLogNEI(),
)
use_output_constraints: bool = True
dump: Optional[str] = None
74 changes: 69 additions & 5 deletions bofire/data_models/strategies/stepwise/conditions.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Comment thread
jduerholt marked this conversation as resolved.
Outdated
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

rather use issues than comments in the code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done: #593

I let the comment in the code so that one knows where to add it in the code.

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):
Expand Down Expand Up @@ -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,
]
30 changes: 18 additions & 12 deletions bofire/strategies/predictives/sobo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -42,9 +47,6 @@
from bofire.data_models.strategies.api import (
MultiplicativeSoboStrategy as MultiplicativeDataModel,
)
from bofire.data_models.strategies.predictives.sobo import (
SoboBaseStrategy as SoboBaseDataModel,
)
from bofire.data_models.strategies.predictives.sobo import SoboStrategy as SoboDataModel
from bofire.strategies.predictives.botorch import BotorchStrategy
from bofire.utils.torch_tools import (
Expand All @@ -61,7 +63,7 @@
class SoboStrategy(BotorchStrategy):
def __init__(
self,
data_model: SoboBaseDataModel,
data_model: SoboDataModel,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why did you change this? multiplicative or additive pass their data model here which is an heir of SoboBaseDataModel.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It was due to some typing errors, which were occuring due the change on the type of the ACQF. And of course, changing it how it is now, also again introduces tying errors. As told above: I am very open for a better solution.

**kwargs,
):
super().__init__(data_model=data_model, **kwargs)
Expand Down Expand Up @@ -111,7 +113,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],
]:
Expand Down Expand Up @@ -165,7 +167,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,
)
Expand All @@ -174,7 +178,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,
Expand Down Expand Up @@ -207,7 +213,7 @@ def __init__(
data_model: AdditiveDataModel,
**kwargs,
):
super().__init__(data_model=data_model, **kwargs)
super().__init__(data_model=data_model, **kwargs) # type: ignore
self.use_output_constraints = data_model.use_output_constraints

def _get_objective_and_constraints(
Expand Down Expand Up @@ -310,7 +316,7 @@ def __init__(
data_model: MultiplicativeDataModel,
**kwargs,
):
super().__init__(data_model=data_model, **kwargs)
super().__init__(data_model=data_model, **kwargs) # type: ignore

def _get_objective_and_constraints(
self,
Expand Down Expand Up @@ -372,7 +378,7 @@ def __init__(
**kwargs,
):
self.additive_features = data_model.additive_features
super().__init__(data_model=data_model, **kwargs)
super().__init__(data_model=data_model, **kwargs) # type: ignore

def _get_objective_and_constraints(
self,
Expand Down Expand Up @@ -440,7 +446,7 @@ def __init__(
data_model: CustomDataModel,
**kwargs,
):
super().__init__(data_model=data_model, **kwargs)
super().__init__(data_model=data_model, **kwargs) # type: ignore
self.use_output_constraints = data_model.use_output_constraints
if data_model.dump is not None:
self.loads(data_model.dump)
Expand Down
Loading