From 4c2f7a803e8a6941f1faf738b9a533e176c7d182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20P=2E=20D=C3=BCrholt?= Date: Wed, 2 Oct 2024 14:17:36 +0200 Subject: [PATCH] add possibility to have objectives modified during runtime --- bofire/data_models/domain/features.py | 10 +- bofire/data_models/features/categorical.py | 4 +- bofire/data_models/features/continuous.py | 4 +- bofire/data_models/objectives/api.py | 7 +- bofire/data_models/objectives/categorical.py | 8 +- bofire/data_models/objectives/identity.py | 18 ++- bofire/data_models/objectives/objective.py | 12 +- bofire/data_models/objectives/sigmoid.py | 47 +++++++- bofire/data_models/objectives/target.py | 16 ++- bofire/plot/objective.py | 7 +- bofire/strategies/predictives/mobo.py | 61 ++-------- bofire/strategies/predictives/qehvi.py | 7 +- bofire/strategies/predictives/qnehvi.py | 5 +- bofire/strategies/predictives/qparego.py | 9 +- bofire/strategies/predictives/sobo.py | 36 ++++-- bofire/utils/multiobjective.py | 6 +- bofire/utils/torch_tools.py | 75 ++++++++++-- .../data_models/features/test_categorical.py | 2 +- tests/bofire/data_models/specs/objectives.py | 4 + tests/bofire/utils/test_torch_tools.py | 108 ++++++++++++++++-- 20 files changed, 332 insertions(+), 114 deletions(-) diff --git a/bofire/data_models/domain/features.py b/bofire/data_models/domain/features.py index 3d0db28ba..a0ea999ac 100644 --- a/bofire/data_models/domain/features.py +++ b/bofire/data_models/domain/features.py @@ -739,7 +739,10 @@ def __call__( """ desis = pd.concat( [ - feat(experiments[f"{feat.key}_pred" if predictions else feat.key]) # type: ignore + feat( + experiments[f"{feat.key}_pred" if predictions else feat.key], + experiments[f"{feat.key}_pred" if predictions else feat.key], + ) # type: ignore for feat in self.features if feat.objective is not None and not isinstance(feat, CategoricalOutput) @@ -747,7 +750,10 @@ def __call__( + [ ( pd.Series( - data=feat(experiments.filter(regex=f"{feat.key}(.*)_prob")), + data=feat( + experiments.filter(regex=f"{feat.key}(.*)_prob"), # type: ignore + experiments.filter(regex=f"{feat.key}(.*)_prob"), # type: ignore + ), name=f"{feat.key}_pred", ) # type: ignore if predictions diff --git a/bofire/data_models/features/categorical.py b/bofire/data_models/features/categorical.py index 584245024..5f065f8e6 100644 --- a/bofire/data_models/features/categorical.py +++ b/bofire/data_models/features/categorical.py @@ -356,14 +356,14 @@ def validate_objective_categories(self): raise ValueError("categories must match to objective categories") return self - def __call__(self, values: pd.Series) -> pd.Series: + def __call__(self, values: pd.Series, values_adapt: pd.Series) -> pd.Series: if self.objective is None: return pd.Series( data=[np.nan for _ in range(len(values))], index=values.index, name=values.name, ) - return self.objective(values) # type: ignore + return self.objective(values, values_adapt) # type: ignore def validate_experimental(self, values: pd.Series) -> pd.Series: values = values.map(str) diff --git a/bofire/data_models/features/continuous.py b/bofire/data_models/features/continuous.py index 52a773afa..818e3cffe 100644 --- a/bofire/data_models/features/continuous.py +++ b/bofire/data_models/features/continuous.py @@ -178,14 +178,14 @@ class ContinuousOutput(Output): default_factory=lambda: MaximizeObjective(w=1.0) ) - def __call__(self, values: pd.Series) -> pd.Series: + def __call__(self, values: pd.Series, values_adapt: pd.Series) -> pd.Series: if self.objective is None: return pd.Series( data=[np.nan for _ in range(len(values))], index=values.index, name=values.name, ) - return self.objective(values) # type: ignore + return self.objective(values, values_adapt) # type: ignore def validate_experimental(self, values: pd.Series) -> pd.Series: try: diff --git a/bofire/data_models/objectives/api.py b/bofire/data_models/objectives/api.py index 1d50e03e2..19b25b19d 100644 --- a/bofire/data_models/objectives/api.py +++ b/bofire/data_models/objectives/api.py @@ -1,8 +1,6 @@ from typing import Union -from bofire.data_models.objectives.categorical import ( - ConstrainedCategoricalObjective, -) +from bofire.data_models.objectives.categorical import ConstrainedCategoricalObjective from bofire.data_models.objectives.identity import ( IdentityObjective, MaximizeObjective, @@ -12,6 +10,7 @@ from bofire.data_models.objectives.sigmoid import ( MaximizeSigmoidObjective, MinimizeSigmoidObjective, + MovingMaximizeSigmoidObjective, SigmoidObjective, ) from bofire.data_models.objectives.target import ( @@ -31,6 +30,7 @@ AnyConstraintObjective = Union[ MaximizeSigmoidObjective, + MovingMaximizeSigmoidObjective, MinimizeSigmoidObjective, TargetObjective, ] @@ -45,4 +45,5 @@ TargetObjective, CloseToTargetObjective, ConstrainedCategoricalObjective, + MovingMaximizeSigmoidObjective, ] diff --git a/bofire/data_models/objectives/categorical.py b/bofire/data_models/objectives/categorical.py index a1fabbf44..077c98b69 100644 --- a/bofire/data_models/objectives/categorical.py +++ b/bofire/data_models/objectives/categorical.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Literal, Union +from typing import Dict, List, Literal, Optional, Union import numpy as np import pandas as pd @@ -61,12 +61,16 @@ def from_dict_label(self) -> Dict: return dict(zip(d.values(), d.keys())) def __call__( - self, x: Union[pd.Series, np.ndarray] + self, + x: Union[pd.Series, np.ndarray], + x_adapt: Optional[Union[pd.Series, np.ndarray]] = None, ) -> Union[pd.Series, np.ndarray, float]: """The call function returning a probabilistic reward for x. Args: x (np.ndarray): A matrix of x values + x_adapt (Optional[np.ndarray], optional): An array of x values which are used to + update the objective parameters on the fly. Defaults to None. Returns: np.ndarray: A reward calculated as inner product of probabilities and feasible objectives. diff --git a/bofire/data_models/objectives/identity.py b/bofire/data_models/objectives/identity.py index eede06e18..3d4713efa 100644 --- a/bofire/data_models/objectives/identity.py +++ b/bofire/data_models/objectives/identity.py @@ -1,4 +1,4 @@ -from typing import Literal, Tuple, Union +from typing import Literal, Optional, Tuple, Union import numpy as np import pandas as pd @@ -48,11 +48,17 @@ def validate_lower_upper(cls, bounds): ) return bounds - def __call__(self, x: Union[pd.Series, np.ndarray]) -> Union[pd.Series, np.ndarray]: + def __call__( + self, + x: Union[pd.Series, np.ndarray], + x_adapt: Optional[Union[pd.Series, np.ndarray]] = None, + ) -> Union[pd.Series, np.ndarray]: """The call function returning a reward for passed x values Args: x (np.ndarray): An array of x values + x_adapt (Optional[np.ndarray], optional): An array of x values which are used to + update the objective parameters on the fly. Defaults to None. Returns: np.ndarray: The identity as reward, might be normalized to the passed lower and upper bounds @@ -81,11 +87,17 @@ class MinimizeObjective(IdentityObjective): type: Literal["MinimizeObjective"] = "MinimizeObjective" - def __call__(self, x: Union[pd.Series, np.ndarray]) -> Union[pd.Series, np.ndarray]: + def __call__( + self, + x: Union[pd.Series, np.ndarray], + x_adapt: Optional[Union[pd.Series, np.ndarray]] = None, + ) -> Union[pd.Series, np.ndarray]: """The call function returning a reward for passed x values Args: x (np.ndarray): An array of x values + x_adapt (Optional[np.ndarray], optional): An array of x values which are used to + update the objective parameters on the fly. Defaults to None. Returns: np.ndarray: The negative identity as reward, might be normalized to the passed lower and upper bounds diff --git a/bofire/data_models/objectives/objective.py b/bofire/data_models/objectives/objective.py index bfae0c62a..913e22caa 100644 --- a/bofire/data_models/objectives/objective.py +++ b/bofire/data_models/objectives/objective.py @@ -1,5 +1,5 @@ from abc import abstractmethod -from typing import Union +from typing import Optional, Union import numpy as np import pandas as pd @@ -15,11 +15,17 @@ class Objective(BaseModel): type: str @abstractmethod - def __call__(self, x: Union[pd.Series, np.ndarray]) -> Union[pd.Series, np.ndarray]: + def __call__( + self, + x: Union[pd.Series, np.ndarray], + x_adapt: Optional[Union[pd.Series, np.ndarray]] = None, + ) -> Union[pd.Series, np.ndarray]: """Abstract method to define the call function for the class Objective Args: - x (np.ndarray): An array of x values + x (np.ndarray): An array of x values for which the objective should be evaluated. + x_adapt (Optional[np.ndarray], optional): An array of x values which are used to + update the objective parameters on the fly. Defaults to None. Returns: np.ndarray: The desirability of the passed x values diff --git a/bofire/data_models/objectives/sigmoid.py b/bofire/data_models/objectives/sigmoid.py index 86a841194..23f593911 100644 --- a/bofire/data_models/objectives/sigmoid.py +++ b/bofire/data_models/objectives/sigmoid.py @@ -1,4 +1,4 @@ -from typing import Literal, Union +from typing import Literal, Optional, Union import numpy as np import pandas as pd @@ -37,11 +37,16 @@ class MaximizeSigmoidObjective(SigmoidObjective): type: Literal["MaximizeSigmoidObjective"] = "MaximizeSigmoidObjective" - def __call__(self, x: Union[pd.Series, np.ndarray]) -> Union[pd.Series, np.ndarray]: + def __call__( + self, + x: Union[pd.Series, np.ndarray], + x_adapt: Optional[Union[pd.Series, np.ndarray]] = None, + ) -> Union[pd.Series, np.ndarray]: """The call function returning a sigmoid shaped reward for passed x values. Args: x (np.ndarray): An array of x values + x_adapt (np.ndarray): An array of x values which are used to update the objective parameters on the fly. Returns: np.ndarray: A reward calculated with a sigmoid function. The stepness and the tipping point can be modified via passed arguments. @@ -49,6 +54,37 @@ def __call__(self, x: Union[pd.Series, np.ndarray]) -> Union[pd.Series, np.ndarr return 1 / (1 + np.exp(-1 * self.steepness * (x - self.tp))) +class MovingMaximizeSigmoidObjective(SigmoidObjective): + type: Literal["MovingMaximizeSigmoidObjective"] = "MovingMaximizeSigmoidObjective" + + def get_adjusted_tp(self, x: Union[pd.Series, np.ndarray]) -> float: + """Get the adjusted turning point for the sigmoid function. + + Args: + x (np.ndarray): An array of x values + + Returns: + float: The adjusted turning point for the sigmoid function. + """ + return x.max() + self.tp + + def __call__( + self, x: Union[pd.Series, np.ndarray], x_adapt: Union[pd.Series, np.ndarray] + ) -> Union[pd.Series, np.ndarray]: + """The call function returning a sigmoid shaped reward for passed x values. + + Args: + x (np.ndarray): An array of x values + x_adapt (np.ndarray): An array of x values which are used to update the objective parameters on the fly. + + Returns: + np.ndarray: A reward calculated with a sigmoid function. The stepness and the tipping point can be modified via passed arguments. + """ + return 1 / ( + 1 + np.exp(-1 * self.steepness * (x - self.get_adjusted_tp(x_adapt))) + ) + + class MinimizeSigmoidObjective(SigmoidObjective): """Class for a minimizing a sigmoid objective @@ -60,11 +96,16 @@ class MinimizeSigmoidObjective(SigmoidObjective): type: Literal["MinimizeSigmoidObjective"] = "MinimizeSigmoidObjective" - def __call__(self, x: Union[pd.Series, np.ndarray]) -> Union[pd.Series, np.ndarray]: + def __call__( + self, + x: Union[pd.Series, np.ndarray], + x_adapt: Optional[Union[pd.Series, np.ndarray]] = None, + ) -> Union[pd.Series, np.ndarray]: """The call function returning a sigmoid shaped reward for passed x values. Args: x (np.ndarray): An array of x values + x_adapt (np.ndarray): An array of x values which are used to update the objective parameters on the fly. Returns: np.ndarray: A reward calculated with a sigmoid function. The stepness and the tipping point can be modified via passed arguments. diff --git a/bofire/data_models/objectives/target.py b/bofire/data_models/objectives/target.py index d11417386..5d18c8572 100644 --- a/bofire/data_models/objectives/target.py +++ b/bofire/data_models/objectives/target.py @@ -1,4 +1,4 @@ -from typing import Literal, Union +from typing import Literal, Optional, Union import numpy as np import pandas as pd @@ -27,7 +27,11 @@ class CloseToTargetObjective(Objective): target_value: float exponent: float - def __call__(self, x: Union[pd.Series, np.ndarray]) -> Union[pd.Series, np.ndarray]: + def __call__( + self, + x: Union[pd.Series, np.ndarray], + x_adapt: Optional[Union[pd.Series, np.ndarray]] = None, + ) -> Union[pd.Series, np.ndarray]: return -1 * (np.abs(x - self.target_value) ** self.exponent) @@ -48,11 +52,17 @@ class TargetObjective(Objective, ConstrainedObjective): tolerance: TGe0 steepness: TGt0 - def __call__(self, x: Union[pd.Series, np.ndarray]) -> Union[pd.Series, np.ndarray]: + def __call__( + self, + x: Union[pd.Series, np.ndarray], + x_adapt: Optional[Union[pd.Series, np.ndarray]] = None, + ) -> Union[pd.Series, np.ndarray]: """The call function returning a reward for passed x values. Args: x (np.array): An array of x values + x_adapt (Optional[np.ndarray], optional): An array of x values which are used to + update the objective parameters on the fly. Defaults to None. Returns: np.array: An array of reward values calculated by the product of two sigmoidal shaped functions resulting in a maximum at the target value. diff --git a/bofire/plot/objective.py b/bofire/plot/objective.py index fec5f690b..9e5592632 100644 --- a/bofire/plot/objective.py +++ b/bofire/plot/objective.py @@ -13,6 +13,7 @@ def plot_objective_plotly( lower: float, upper: float, values: Optional[pd.Series] = None, + adapt_values: Optional[pd.Series] = None, layout_options: Optional[Dict] = None, ): """Plot the assigned objective. @@ -22,6 +23,8 @@ def plot_objective_plotly( lower (float): lower bound for the plot upper (float): upper bound for the plot values (Optional[pd.Series], optional): If provided, scatter also the historical data in the plot. Defaults to None. + adapt_values (Optional[pd.Series], optional): If provided, adapt the objective function to the passed values. + Defaults to None. layout_options: (Dict, optional): Options that are passed to plotlys `update_layout`. """ if feature.objective is None: @@ -30,14 +33,14 @@ def plot_objective_plotly( ) x = pd.Series(np.linspace(lower, upper, 5000)) - reward = feature.objective.__call__(x) + reward = feature.objective.__call__(x, x_adapt=adapt_values) # type: ignore fig1 = px.line(x=x, y=reward, title=feature.key) if values is not None: fig2 = px.scatter( x=values, - y=feature.objective.__call__(values), + y=feature.objective.__call__(values, x_adapt=adapt_values), # type: ignore ) fig = go.Figure(data=fig1.data + fig2.data) # type: ignore else: diff --git a/bofire/strategies/predictives/mobo.py b/bofire/strategies/predictives/mobo.py index 039084912..e775bafd2 100644 --- a/bofire/strategies/predictives/mobo.py +++ b/bofire/strategies/predictives/mobo.py @@ -42,11 +42,14 @@ def __init__( def _get_acqfs(self, n) -> List[AcquisitionFunction]: assert self.is_fitted is True, "Model not trained." + assert self.experiments is not None, "No experiments available." X_train, X_pending = self.get_acqf_input_tensors() # get etas and constraints - constraints, etas = get_output_constraints(self.domain.outputs) + constraints, etas = get_output_constraints( + self.domain.outputs, experiments=self.experiments + ) if len(constraints) == 0: constraints, etas = None, 1e-3 else: @@ -86,61 +89,15 @@ def _get_acqfs(self, n) -> List[AcquisitionFunction]: ) return [acqf] - # def _get_acqfs( - # self, n - # ) -> List[ - # Union[ - # qExpectedHypervolumeImprovement, - # qNoisyExpectedHypervolumeImprovement, - # qLogNoisyExpectedHypervolumeImprovement, - # qLogExpectedHypervolumeImprovement, - # ] - # ]: - # df = self.domain.outputs.preprocess_experiments_all_valid_outputs( - # self.experiments - # ) - - # train_obj = ( - # df[self.domain.outputs.get_keys_by_objective(excludes=None)].values - # * self.ref_point_mask - # ) - # ref_point = self.get_adjusted_refpoint() - # weights = np.array( - # [ - # feat.objective.w # type: ignore - # for feat in self.domain.outputs.get_by_objective(excludes=None) - # ] - # ) - # # compute points that are better than the known reference point - # better_than_ref = (train_obj > ref_point).all(axis=-1) - # # partition non-dominated space into disjoint rectangles - # partitioning = NondominatedPartitioning( - # ref_point=torch.from_numpy(ref_point * weights), - # # use observations that are better than the specified reference point and feasible - # Y=torch.from_numpy(train_obj[better_than_ref]), - # ) - - # _, X_pending = self.get_acqf_input_tensors() - - # assert self.model is not None - # # setup the acqf - # acqf = qExpectedHypervolumeImprovement( - # model=self.model, - # ref_point=ref_point, # use known reference point - # partitioning=partitioning, - # # sampler=self.sampler, - # # define an objective that specifies which outcomes are the objectives - # objective=self._get_objective(), - # X_pending=X_pending, - # ) - # acqf._default_sample_shape = torch.Size([self.num_sobol_samples]) - # return [acqf] - def _get_objective(self) -> GenericMCMultiOutputObjective: - objective = get_multiobjective_objective(outputs=self.domain.outputs) + assert self.experiments is not None + objective = get_multiobjective_objective( + outputs=self.domain.outputs, experiments=self.experiments + ) return GenericMCMultiOutputObjective(objective=objective) def get_adjusted_refpoint(self) -> List[float]: + assert self.experiments is not None, "No experiments available." if self.ref_point is None: df = self.domain.outputs.preprocess_experiments_all_valid_outputs( self.experiments diff --git a/bofire/strategies/predictives/qehvi.py b/bofire/strategies/predictives/qehvi.py index ced035d1f..d0c8d80ac 100644 --- a/bofire/strategies/predictives/qehvi.py +++ b/bofire/strategies/predictives/qehvi.py @@ -35,6 +35,7 @@ def __init__( objective: Optional[MCMultiOutputObjective] = None def _get_acqfs(self, n) -> List[qExpectedHypervolumeImprovement]: + assert self.experiments is not None, "No experiments available." df = self.domain.outputs.preprocess_experiments_all_valid_outputs( self.experiments ) @@ -76,10 +77,14 @@ def _get_acqfs(self, n) -> List[qExpectedHypervolumeImprovement]: return [acqf] def _get_objective(self) -> GenericMCMultiOutputObjective: - objective = get_multiobjective_objective(outputs=self.domain.outputs) + assert self.experiments is not None, "No experiments available." + objective = get_multiobjective_objective( + outputs=self.domain.outputs, experiments=self.experiments + ) return GenericMCMultiOutputObjective(objective=objective) def get_adjusted_refpoint(self) -> List[float]: + assert self.experiments is not None, "No experiments available." if self.ref_point is None: df = self.domain.outputs.preprocess_experiments_all_valid_outputs( self.experiments diff --git a/bofire/strategies/predictives/qnehvi.py b/bofire/strategies/predictives/qnehvi.py index a18e18078..1ddd81518 100644 --- a/bofire/strategies/predictives/qnehvi.py +++ b/bofire/strategies/predictives/qnehvi.py @@ -21,10 +21,13 @@ def __init__( self.alpha = data_model.alpha def _get_acqfs(self, n) -> List[qNoisyExpectedHypervolumeImprovement]: + assert self.experiments is not None, "No experiments available." X_train, X_pending = self.get_acqf_input_tensors() # get etas and constraints - constraints, etas = get_output_constraints(self.domain.outputs) + constraints, etas = get_output_constraints( + self.domain.outputs, experiments=self.experiments + ) if len(constraints) == 0: constraints, etas = None, 1e-3 else: diff --git a/bofire/strategies/predictives/qparego.py b/bofire/strategies/predictives/qparego.py index a3d98ae8d..aa9f93153 100644 --- a/bofire/strategies/predictives/qparego.py +++ b/bofire/strategies/predictives/qparego.py @@ -51,6 +51,7 @@ def _get_objective_and_constraints( Union[ConstrainedObjective, None]: the botorch constraints. Union[List, float]: etas used in the botorch constraints. """ + assert self.experiments is not None, "No experiments available." ref_point_mask = torch.from_numpy(get_ref_point_mask(domain=self.domain)).to( **tkwargs ) @@ -70,7 +71,9 @@ def _get_objective_and_constraints( * ref_point_mask ) - obj_callable = get_multiobjective_objective(outputs=self.domain.outputs) + obj_callable = get_multiobjective_objective( + outputs=self.domain.outputs, experiments=self.experiments + ) df_preds = self.predict( self.domain.outputs.preprocess_experiments_any_valid_output( @@ -90,7 +93,9 @@ def objective_callable(Z, X=None): return scalarization(obj_callable(Z, None) * ref_point_mask, X) if len(weights) != len(self.domain.outputs.get_by_objective(Objective)): - constraint_callables, etas = get_output_constraints(self.domain.outputs) + constraint_callables, etas = get_output_constraints( + self.domain.outputs, experiments=self.experiments + ) else: constraint_callables, etas = None, 1e-3 diff --git a/bofire/strategies/predictives/sobo.py b/bofire/strategies/predictives/sobo.py index edf8fce96..c75e0bada 100644 --- a/bofire/strategies/predictives/sobo.py +++ b/bofire/strategies/predictives/sobo.py @@ -91,6 +91,7 @@ def _get_objective_and_constraints( Union[List[Callable[[torch.Tensor], torch.Tensor]], None], Union[List, float], ]: + assert self.experiments is not None, "No experiments available." try: target_feature = self.domain.outputs.get_by_objective( excludes=ConstrainedObjective @@ -98,8 +99,13 @@ def _get_objective_and_constraints( except IndexError: target_feature = self.domain.outputs.get_by_objective(includes=Objective)[0] target_index = self.domain.outputs.get_keys().index(target_feature.key) + x_adapt = torch.from_numpy( + self.domain.outputs.preprocess_experiments_one_valid_output( + target_feature.key, self.experiments + )[target_feature.key].values + ).to(**tkwargs) objective_callable = get_objective_callable( - idx=target_index, objective=target_feature.objective + idx=target_index, objective=target_feature.objective, x_adapt=x_adapt ) # get the constraints @@ -107,7 +113,7 @@ def _get_objective_and_constraints( len(self.domain.outputs.get_by_objective(Objective)) > 1 ): constraint_callables, etas = get_output_constraints( - outputs=self.domain.outputs + outputs=self.domain.outputs, experiments=self.experiments ) else: constraint_callables, etas = None, 1e-3 @@ -153,6 +159,7 @@ def _get_objective_and_constraints( Union[List[Callable[[torch.Tensor], torch.Tensor]], None], Union[List, float], ]: + assert self.experiments is not None, "No experiments available." # get the constraints if ( (len(self.domain.outputs.get_by_objective(ConstrainedObjective)) > 0) @@ -160,14 +167,16 @@ def _get_objective_and_constraints( and self.use_output_constraints ): constraint_callables, etas = get_output_constraints( - outputs=self.domain.outputs + outputs=self.domain.outputs, experiments=self.experiments ) else: constraint_callables, etas = None, 1e-3 # TODO: test this if self.use_output_constraints: objective_callable = get_additive_botorch_objective( - outputs=self.domain.outputs, exclude_constraints=True + outputs=self.domain.outputs, + exclude_constraints=True, + experiments=self.experiments, ) # special cases of qUCB and qSR do not work with separate constraints @@ -219,10 +228,12 @@ def _get_objective_and_constraints( Union[List, float], ]: # we absorb all constraints into the objective + assert self.experiments is not None, "No experiments available." return ( GenericMCObjective( - objective=get_multiplicative_botorch_objective( # type: ignore - outputs=self.domain.outputs + objective=get_multiplicative_botorch_objective( + outputs=self.domain.outputs, + experiments=self.experiments, # type: ignore ) ), None, @@ -250,6 +261,7 @@ def _get_objective_and_constraints( Union[List[Callable[[torch.Tensor], torch.Tensor]], None], Union[List, float], ]: + assert self.experiments is not None, "No experiments available." if self.f is None: raise ValueError("No function has been provided for the strategy") # get the constraints @@ -259,14 +271,17 @@ def _get_objective_and_constraints( and self.use_output_constraints ): constraint_callables, etas = get_output_constraints( - outputs=self.domain.outputs + outputs=self.domain.outputs, experiments=self.experiments ) else: constraint_callables, etas = None, 1e-3 if self.use_output_constraints: objective_callable = get_custom_botorch_objective( - outputs=self.domain.outputs, f=self.f, exclude_constraints=True + outputs=self.domain.outputs, + f=self.f, + exclude_constraints=True, + experiments=self.experiments, ) # special cases of qUCB and qSR do not work with separate constraints if isinstance(self.acquisition_function, (qSR, qUCB)): @@ -293,7 +308,10 @@ def _get_objective_and_constraints( return ( GenericMCObjective( objective=get_custom_botorch_objective( - outputs=self.domain.outputs, f=self.f, exclude_constraints=False + outputs=self.domain.outputs, + f=self.f, + exclude_constraints=False, + experiments=self.experiments, # type: ignore ) ), constraint_callables, diff --git a/bofire/utils/multiobjective.py b/bofire/utils/multiobjective.py index 449ba82fc..c1c4f3d66 100644 --- a/bofire/utils/multiobjective.py +++ b/bofire/utils/multiobjective.py @@ -85,7 +85,9 @@ def compute_hypervolume( outputs = domain.outputs.get_by_objective( includes=[MaximizeObjective, MinimizeObjective, CloseToTargetObjective] ) - objective = get_multiobjective_objective(outputs=outputs) + objective = get_multiobjective_objective( + outputs=outputs, experiments=optimal_experiments + ) # type: ignore ref_point_mask = torch.from_numpy(get_ref_point_mask(domain)).to(**tkwargs) hv = Hypervolume( ref_point=torch.tensor( @@ -127,7 +129,7 @@ def infer_ref_point( includes=[MaximizeObjective, MinimizeObjective, CloseToTargetObjective] ) keys = [f.key for f in outputs] - objective = get_multiobjective_objective(outputs=outputs) + objective = get_multiobjective_objective(outputs=outputs, experiments=experiments) # type: ignore df = domain.outputs.preprocess_experiments_all_valid_outputs( experiments, output_feature_keys=keys diff --git a/bofire/utils/torch_tools.py b/bofire/utils/torch_tools.py index ca0e73f5f..9b1e5f43b 100644 --- a/bofire/utils/torch_tools.py +++ b/bofire/utils/torch_tools.py @@ -2,6 +2,7 @@ from typing import Callable, Dict, List, Optional, Tuple, Type, Union import numpy as np +import pandas as pd import torch from torch import Tensor @@ -22,6 +23,7 @@ MaximizeSigmoidObjective, MinimizeObjective, MinimizeSigmoidObjective, + MovingMaximizeSigmoidObjective, TargetObjective, ) from bofire.strategies.strategy import Strategy @@ -225,7 +227,7 @@ def get_nonlinear_constraints(domain: Domain) -> List[Callable[[Tensor], float]] def constrained_objective2botorch( - idx: int, objective: ConstrainedObjective, eps: float = 1e-8 + idx: int, objective: ConstrainedObjective, x_adapt: Tensor, eps: float = 1e-8 ) -> Tuple[List[Callable[[Tensor], Tensor]], List[float], int]: """Create a callable that can be used by `botorch.utils.objective.apply_constraints` to setup ouput constrained optimizations. @@ -247,6 +249,13 @@ def constrained_objective2botorch( [1.0 / objective.steepness], idx + 1, ) + elif isinstance(objective, MovingMaximizeSigmoidObjective): + tp = x_adapt.max().item() + objective.tp + return ( + [lambda Z: (Z[..., idx] - tp) * -1.0], + [1.0 / objective.steepness], + idx + 1, + ) elif isinstance(objective, MinimizeSigmoidObjective): return ( [lambda Z: (Z[..., idx] - objective.tp)], @@ -291,7 +300,7 @@ def constrained_objective2botorch( def get_output_constraints( - outputs: Outputs, + outputs: Outputs, experiments: pd.DataFrame ) -> Tuple[List[Callable[[Tensor], Tensor]], List[float]]: """Method to translate output constraint objectives into a list of callables and list of etas for use in botorch. @@ -309,9 +318,16 @@ def get_output_constraints( idx = 0 for feat in outputs.get(): if isinstance(feat.objective, ConstrainedObjective): # type: ignore + cleaned_experiments = outputs.preprocess_experiments_one_valid_output( + feat.key, experiments + ) + x_adapt = torch.from_numpy(cleaned_experiments[feat.key].values).to( + **tkwargs + ) iconstraints, ietas, idx = constrained_objective2botorch( idx, objective=feat.objective, # type: ignore + x_adapt=x_adapt, ) constraints += iconstraints etas += ietas @@ -321,7 +337,7 @@ def get_output_constraints( def get_objective_callable( - idx: int, objective: AnyObjective + idx: int, objective: AnyObjective, x_adapt: Tensor ) -> Callable[[Tensor, Optional[Tensor]], Tensor]: # type: ignore if isinstance(objective, MaximizeObjective): return lambda y, X=None: ( @@ -354,6 +370,11 @@ def get_objective_callable( + torch.exp(-1.0 * objective.steepness * (y[..., idx] - objective.tp)) ) ) + if isinstance(objective, MovingMaximizeSigmoidObjective): + tp = x_adapt.max().item() + objective.tp + return lambda y, X=None: ( + 1.0 / (1.0 + torch.exp(-1.0 * objective.steepness * (y[..., idx] - tp))) + ) if isinstance(objective, TargetObjective): return lambda y, X=None: ( 1.0 @@ -395,10 +416,19 @@ def get_custom_botorch_objective( ], Tensor, ], + experiments: pd.DataFrame, exclude_constraints: bool = True, ) -> Callable[[Tensor, Tensor], Tensor]: callables = [ - get_objective_callable(idx=i, objective=feat.objective) # type: ignore + get_objective_callable( + idx=i, + objective=feat.objective, + x_adapt=torch.from_numpy( + outputs.preprocess_experiments_one_valid_output(feat.key, experiments)[ + feat.key + ].values + ).to(**tkwargs), + ) # type: ignore for i, feat in enumerate(outputs.get()) if feat.objective is not None # type: ignore and ( @@ -426,9 +456,18 @@ def objective(samples: torch.Tensor, X: torch.Tensor) -> torch.Tensor: def get_multiplicative_botorch_objective( outputs: Outputs, + experiments: pd.DataFrame, ) -> Callable[[Tensor, Tensor], Tensor]: callables = [ - get_objective_callable(idx=i, objective=feat.objective) # type: ignore + get_objective_callable( + idx=i, + objective=feat.objective, + x_adapt=torch.from_numpy( + outputs.preprocess_experiments_one_valid_output(feat.key, experiments)[ + feat.key + ].values + ).to(**tkwargs), + ) # type: ignore for i, feat in enumerate(outputs.get()) if feat.objective is not None # type: ignore ] @@ -448,10 +487,20 @@ def objective(samples: torch.Tensor, X: torch.Tensor) -> torch.Tensor: def get_additive_botorch_objective( - outputs: Outputs, exclude_constraints: bool = True + outputs: Outputs, + experiments: pd.DataFrame, + exclude_constraints: bool = True, ) -> Callable[[Tensor, Tensor], Tensor]: callables = [ - get_objective_callable(idx=i, objective=feat.objective) # type: ignore + get_objective_callable( + idx=i, + objective=feat.objective, + x_adapt=torch.from_numpy( + outputs.preprocess_experiments_one_valid_output(feat.key, experiments)[ + feat.key + ].values + ).to(**tkwargs), + ) # type: ignore for i, feat in enumerate(outputs.get()) if feat.objective is not None # type: ignore and ( @@ -481,7 +530,7 @@ def objective(samples: Tensor, X: Tensor) -> Tensor: def get_multiobjective_objective( - outputs: Outputs, + outputs: Outputs, experiments: pd.DataFrame ) -> Callable[[Tensor, Optional[Tensor]], Tensor]: """Returns @@ -492,7 +541,15 @@ def get_multiobjective_objective( Callable[[Tensor], Tensor]: _description_ """ callables = [ - get_objective_callable(idx=i, objective=feat.objective) # type: ignore + get_objective_callable( + idx=i, + objective=feat.objective, + x_adapt=torch.from_numpy( + outputs.preprocess_experiments_one_valid_output(feat.key, experiments)[ + feat.key + ].values + ).to(**tkwargs), + ) # type: ignore for i, feat in enumerate(outputs.get()) if feat.objective is not None # type: ignore and isinstance( diff --git a/tests/bofire/data_models/features/test_categorical.py b/tests/bofire/data_models/features/test_categorical.py index d7699d9d0..acd1862ef 100644 --- a/tests/bofire/data_models/features/test_categorical.py +++ b/tests/bofire/data_models/features/test_categorical.py @@ -473,5 +473,5 @@ def test_categorical_output_call(): categories=["c1", "c2"], desirability=[True, False] ), ) - output = categorical_output(test_df) + output = categorical_output(test_df, test_df) assert output.tolist() == test_df["c1"].tolist() diff --git a/tests/bofire/data_models/specs/objectives.py b/tests/bofire/data_models/specs/objectives.py index 4544811bc..778e9ff0e 100644 --- a/tests/bofire/data_models/specs/objectives.py +++ b/tests/bofire/data_models/specs/objectives.py @@ -28,6 +28,10 @@ lambda: {"w": 1.0, "bounds": (0.1, 0.9)}, ) +specs.add_valid( + objectives.MovingMaximizeSigmoidObjective, + lambda: {"w": 1.0, "tp": 0.3, "steepness": 0.2}, +) specs.add_valid( objectives.MinimizeSigmoidObjective, diff --git a/tests/bofire/utils/test_torch_tools.py b/tests/bofire/utils/test_torch_tools.py index fb637a5ef..2e2a1b9a2 100644 --- a/tests/bofire/utils/test_torch_tools.py +++ b/tests/bofire/utils/test_torch_tools.py @@ -1,6 +1,7 @@ import random import numpy as np +import pandas as pd import pytest import torch from botorch.acquisition.objective import ConstrainedMCObjective, GenericMCObjective @@ -29,6 +30,7 @@ MaximizeSigmoidObjective, MinimizeObjective, MinimizeSigmoidObjective, + MovingMaximizeSigmoidObjective, TargetObjective, ) from bofire.data_models.strategies.api import RandomStrategy @@ -94,21 +96,35 @@ MinimizeSigmoidObjective(steepness=1.0, tp=1.0, w=0.5), TargetObjective(target_value=2.0, steepness=1.0, tolerance=1e-3, w=0.5), CloseToTargetObjective(target_value=2.0, exponent=1.0, w=0.5), + MovingMaximizeSigmoidObjective(steepness=1, tp=-1, w=1), # ConstantObjective(w=0.5, value=1.0), ], ) def test_get_objective_callable(objective): samples = (torch.rand(50, 3, requires_grad=True) * 5.0).to(**tkwargs) a_samples = samples.detach().numpy() - callable = get_objective_callable(idx=1, objective=objective) + x_adapt = (torch.rand(10) * 3).to(**tkwargs) + callable = get_objective_callable(idx=1, objective=objective, x_adapt=x_adapt) assert np.allclose( # objective.reward(samples, desFunc)[0].detach().numpy(), callable(samples).detach().numpy(), - objective(a_samples[:, 1]), + objective.__call__(a_samples[:, 1], x_adapt=x_adapt.numpy()), rtol=1e-06, ) +def test_moving_maximize_sigmoid_objective(): + samples = (torch.rand(50, 3, requires_grad=True) * 5.0).to(**tkwargs) + a_samples = samples.detach().numpy() + x_adapt = torch.tensor([1.0, 2.0, 3.0]).to(**tkwargs) + o1 = MovingMaximizeSigmoidObjective(steepness=1, tp=-1, w=1) + o2 = MaximizeSigmoidObjective(steepness=1.0, tp=2, w=1) + assert np.allclose( + o1.__call__(a_samples[:, 1], x_adapt=x_adapt.numpy()), + o2.__call__(a_samples[:, 1], x_adapt=x_adapt.numpy()), + ) + + def f1(samples, callables, weights, X): outputs_list = [] for c, w in zip(callables, weights): @@ -133,6 +149,16 @@ def f2(samples, callables, weights, X): @pytest.mark.parametrize("f, exclude_constraints", [(f1, True), (f2, False)]) def test_get_custom_botorch_objective(f, exclude_constraints): + experiments = pd.DataFrame( + { + "alpha": np.random.rand(10), + "beta": np.random.rand(10), + "gamma": np.random.rand(10), + "valid_alpha": [1] * 10, + "valid_beta": [1] * 10, + "valid_gamma": [1] * 10, + } + ) samples = (torch.rand(30, 3, requires_grad=True) * 5).to(**tkwargs) a_samples = samples.detach().numpy() obj1 = MaximizeObjective(w=1.0) @@ -155,7 +181,7 @@ def test_get_custom_botorch_objective(f, exclude_constraints): ] ) objective = get_custom_botorch_objective( - outputs, f=f, exclude_constraints=exclude_constraints + outputs, f=f, exclude_constraints=exclude_constraints, experiments=experiments ) generic_objective = GenericMCObjective(objective=objective) objective_forward = generic_objective.forward(samples) @@ -178,7 +204,9 @@ def test_get_custom_botorch_objective(f, exclude_constraints): rtol=1e-06, ) if exclude_constraints: - constraints, etas = get_output_constraints(outputs=outputs) + constraints, etas = get_output_constraints( + outputs=outputs, experiments=experiments + ) generic_objective = ConstrainedMCObjective( objective=objective, constraints=constraints, @@ -201,6 +229,14 @@ def test_get_custom_botorch_objective(f, exclude_constraints): def test_get_multiplicative_botorch_objective(): + experiments = pd.DataFrame( + { + "alpha": np.random.rand(10), + "beta": np.random.rand(10), + "valid_alpha": [1] * 10, + "valid_beta": [1] * 10, + } + ) (obj1, obj2) = random.choices( [ MaximizeObjective(w=0.5), @@ -218,7 +254,7 @@ def test_get_multiplicative_botorch_objective(): ContinuousOutput(key="beta", objective=obj2), ] ) - objective = get_multiplicative_botorch_objective(outputs) + objective = get_multiplicative_botorch_objective(outputs, experiments=experiments) generic_objective = GenericMCObjective(objective=objective) samples = (torch.rand(30, 2, requires_grad=True) * 5).to(**tkwargs) a_samples = samples.detach().numpy() @@ -237,6 +273,16 @@ def test_get_multiplicative_botorch_objective(): @pytest.mark.parametrize("exclude_constraints", [True, False]) def test_get_additive_botorch_objective(exclude_constraints): + experiments = pd.DataFrame( + { + "alpha": np.random.rand(10), + "beta": np.random.rand(10), + "gamma": np.random.rand(10), + "valid_alpha": [1] * 10, + "valid_beta": [1] * 10, + "valid_gamma": [1] * 10, + } + ) samples = (torch.rand(30, 3, requires_grad=True) * 5).to(**tkwargs) a_samples = samples.detach().numpy() obj1 = MaximizeObjective(w=0.5) @@ -260,7 +306,7 @@ def test_get_additive_botorch_objective(exclude_constraints): ] ) objective = get_additive_botorch_objective( - outputs, exclude_constraints=exclude_constraints + outputs, exclude_constraints=exclude_constraints, experiments=experiments ) generic_objective = GenericMCObjective(objective=objective) objective_forward = generic_objective.forward(samples) @@ -281,7 +327,9 @@ def test_get_additive_botorch_objective(exclude_constraints): rtol=1e-06, ) if exclude_constraints: - constraints, etas = get_output_constraints(outputs=outputs) + constraints, etas = get_output_constraints( + outputs=outputs, experiments=experiments + ) generic_objective = ConstrainedMCObjective( objective=objective, constraints=constraints, @@ -480,7 +528,17 @@ def test_get_linear_constraints_unit_scaled(): ], ) def test_get_output_constraints(outputs): - constraints, etas = get_output_constraints(outputs=outputs) + experiments = pd.DataFrame( + { + "of1": np.random.rand(10), + "of2": np.random.rand(10), + "of3": np.random.rand(10), + "valid_of1": [1] * 10, + "valid_of2": [1] * 10, + "valid_of3": [1] * 10, + } + ) + constraints, etas = get_output_constraints(outputs=outputs, experiments=experiments) assert len(constraints) == len(etas) assert np.allclose(etas, [0.5, 0.25, 0.25]) @@ -763,7 +821,19 @@ def test_get_multiobjective_objective(): ), ] ) - objective = get_multiobjective_objective(outputs=outputs) + experiments = pd.DataFrame( + { + "alpha": np.random.rand(10), + "beta": np.random.rand(10), + "gamma": np.random.rand(10), + "omega": np.random.rand(10), + "valid_alpha": [1] * 10, + "valid_beta": [1] * 10, + "valid_gamma": [1] * 10, + "valid_omega": [1] * 10, + } + ) + objective = get_multiobjective_objective(outputs=outputs, experiments=experiments) generic_objective = GenericMCObjective(objective=objective) # check the shape objective_forward = generic_objective.forward(samples2) @@ -821,10 +891,14 @@ def test_get_initial_conditions_generator(sequential: bool): (MaximizeSigmoidObjective(w=1, tp=15, steepness=0.5)), (MinimizeSigmoidObjective(w=1, tp=15, steepness=0.5)), (TargetObjective(w=1, target_value=15, steepness=2, tolerance=5)), + (MovingMaximizeSigmoidObjective(w=1, tp=-1, steepness=0.5)), ], ) def test_constrained_objective2botorch(objective): - cs, etas, _ = constrained_objective2botorch(idx=0, objective=objective) + x_adapt = torch.tensor([1.0, 2.0, 3.0]).to(**tkwargs) + cs, etas, _ = constrained_objective2botorch( + idx=0, objective=objective, x_adapt=x_adapt + ) x = torch.from_numpy(np.linspace(0, 30, 500)).unsqueeze(-1).to(**tkwargs) @@ -840,7 +914,17 @@ def test_constrained_objective2botorch(objective): .ravel() ) - assert np.allclose(objective.__call__(np.linspace(0, 30, 500)), result) + assert np.allclose( + objective.__call__(np.linspace(0, 30, 500), x_adapt=x_adapt.numpy()), result + ) + if isinstance(objective, MovingMaximizeSigmoidObjective): + objective2 = MaximizeSigmoidObjective( + w=1, tp=x_adapt.max().item() + objective.tp, steepness=objective.steepness + ) + assert np.allclose( + objective2.__call__(np.linspace(0, 30, 500), x_adapt=x_adapt.numpy()), + result, + ) def test_constrained_objective(): @@ -848,7 +932,7 @@ def test_constrained_objective(): obj1 = ConstrainedCategoricalObjective( categories=["c1", "c2", "c3"], desirability=desirability ) - cs, etas, _ = constrained_objective2botorch(idx=0, objective=obj1) + cs, etas, _ = constrained_objective2botorch(idx=0, objective=obj1, x_adapt=None) x = torch.zeros((50, 3)) x[:, 0] = torch.arange(50) / 50