From bdd1e90982f7b56b03f1f0f6cd13d46f300d7cdd Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 2 Dec 2024 16:43:36 +0100 Subject: [PATCH 01/47] Improve deprecation warning message --- baybe/objectives/desirability.py | 2 +- baybe/objectives/single.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/baybe/objectives/desirability.py b/baybe/objectives/desirability.py index faebf925e1..ff4d74bc55 100644 --- a/baybe/objectives/desirability.py +++ b/baybe/objectives/desirability.py @@ -145,7 +145,7 @@ def transform( # >>>>>>>>>> Deprecation if not ((df is None) ^ (data is None)): raise ValueError( - "Provide the dataframe to be transformed as argument to `df`." + "Provide the dataframe to be transformed as first positional argument." ) if data is not None: diff --git a/baybe/objectives/single.py b/baybe/objectives/single.py index 6f9ba8e455..4b3bac56f5 100644 --- a/baybe/objectives/single.py +++ b/baybe/objectives/single.py @@ -53,7 +53,7 @@ def transform( # >>>>>>>>>> Deprecation if not ((df is None) ^ (data is None)): raise ValueError( - "Provide the dataframe to be transformed as argument to `df`." + "Provide the dataframe to be transformed as first positional argument." ) if data is not None: From 5e520393378f40e731eae198ac68c4f41e94fc22 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 2 Dec 2024 16:51:32 +0100 Subject: [PATCH 02/47] Draft ParetoObjective class --- baybe/objectives/__init__.py | 2 + baybe/objectives/pareto.py | 83 ++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 baybe/objectives/pareto.py diff --git a/baybe/objectives/__init__.py b/baybe/objectives/__init__.py index 30a79bb161..a1c515a88d 100644 --- a/baybe/objectives/__init__.py +++ b/baybe/objectives/__init__.py @@ -1,9 +1,11 @@ """BayBE objectives.""" from baybe.objectives.desirability import DesirabilityObjective +from baybe.objectives.pareto import ParetoObjective from baybe.objectives.single import SingleTargetObjective __all__ = [ "SingleTargetObjective", "DesirabilityObjective", + "ParetoObjective", ] diff --git a/baybe/objectives/pareto.py b/baybe/objectives/pareto.py new file mode 100644 index 0000000000..b24c8c6092 --- /dev/null +++ b/baybe/objectives/pareto.py @@ -0,0 +1,83 @@ +"""Functionality for multi-target objectives.""" + +import warnings + +import pandas as pd +from attrs import define, field +from attrs.validators import deep_iterable, instance_of, min_len +from typing_extensions import override + +from baybe.objectives.base import Objective +from baybe.targets.base import Target +from baybe.utils.basic import to_tuple +from baybe.utils.dataframe import get_transform_objects + + +@define(frozen=True, slots=False) +class ParetoObjective(Objective): + """An objective handling multiple targets in a Pareto sense.""" + + _targets: tuple[Target, ...] = field( + converter=to_tuple, + validator=[min_len(2), deep_iterable(member_validator=instance_of(Target))], + alias="targets", + ) + "The targets considered by the objective." + + @override + @property + def targets(self) -> tuple[Target, ...]: + return self._targets + + @override + @override + def transform( + self, + df: pd.DataFrame | None = None, + /, + *, + allow_missing: bool = False, + allow_extra: bool | None = None, + data: pd.DataFrame | None = None, + ) -> pd.DataFrame: + # >>>>>>>>>> Deprecation + if not ((df is None) ^ (data is None)): + raise ValueError( + "Provide the dataframe to be transformed as first positional argument." + ) + + if data is not None: + df = data + warnings.warn( + "Providing the dataframe via the `data` argument is deprecated and " + "will be removed in a future version. Please pass your dataframe " + "as positional argument instead.", + DeprecationWarning, + ) + + # Mypy does not infer from the above that `df` must be a dataframe here + assert isinstance(df, pd.DataFrame) + + if allow_extra is None: + allow_extra = True + if set(df.columns) - {p.name for p in self.targets}: + warnings.warn( + "For backward compatibility, the new `allow_extra` flag is set " + "to `True` when left unspecified. However, this behavior will be " + "changed in a future version. If you want to invoke the old " + "behavior, please explicitly set `allow_extra=True`.", + DeprecationWarning, + ) + # <<<<<<<<<< Deprecation + + # Extract the relevant part of the dataframe + targets = get_transform_objects( + df, self.targets, allow_missing=allow_missing, allow_extra=allow_extra + ) + transformed = df[[t.name for t in targets]].copy() + + # Transform all targets individually + for target in self.targets: + transformed[target.name] = target.transform(df[target.name]) + + return transformed From 460bbc1b7ca108515507caf0cd9898822f0e15ca Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 2 Dec 2024 19:23:38 +0100 Subject: [PATCH 03/47] Extract function for transforming target columns --- baybe/objectives/desirability.py | 11 +++-------- baybe/objectives/pareto.py | 12 ++---------- baybe/utils/dataframe.py | 25 +++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/baybe/objectives/desirability.py b/baybe/objectives/desirability.py index ff4d74bc55..21c883d417 100644 --- a/baybe/objectives/desirability.py +++ b/baybe/objectives/desirability.py @@ -18,7 +18,7 @@ from baybe.targets.base import Target from baybe.targets.numerical import NumericalTarget from baybe.utils.basic import is_all_instance, to_tuple -from baybe.utils.dataframe import get_transform_objects, pretty_print_df +from baybe.utils.dataframe import pretty_print_df, transform_target_columns from baybe.utils.numerical import geom_mean from baybe.utils.plotting import to_string from baybe.utils.validation import finite_float @@ -172,15 +172,10 @@ def transform( ) # <<<<<<<<<< Deprecation - # Extract the relevant part of the dataframe - targets = get_transform_objects( + # Transform all targets individually + transformed = transform_target_columns( df, self.targets, allow_missing=allow_missing, allow_extra=allow_extra ) - transformed = df[[t.name for t in targets]].copy() - - # Transform all targets individually - for target in self.targets: - transformed[target.name] = target.transform(df[target.name]) # Scalarize the transformed targets into desirability values vals = scalarize(transformed.values, self.scalarizer, self._normalized_weights) diff --git a/baybe/objectives/pareto.py b/baybe/objectives/pareto.py index b24c8c6092..21cd0b4074 100644 --- a/baybe/objectives/pareto.py +++ b/baybe/objectives/pareto.py @@ -10,7 +10,7 @@ from baybe.objectives.base import Objective from baybe.targets.base import Target from baybe.utils.basic import to_tuple -from baybe.utils.dataframe import get_transform_objects +from baybe.utils.dataframe import transform_target_columns @define(frozen=True, slots=False) @@ -70,14 +70,6 @@ def transform( ) # <<<<<<<<<< Deprecation - # Extract the relevant part of the dataframe - targets = get_transform_objects( + return transform_target_columns( df, self.targets, allow_missing=allow_missing, allow_extra=allow_extra ) - transformed = df[[t.name for t in targets]].copy() - - # Transform all targets individually - for target in self.targets: - transformed[target.name] = target.transform(df[target.name]) - - return transformed diff --git a/baybe/utils/dataframe.py b/baybe/utils/dataframe.py index 7065530746..8379d1289d 100644 --- a/baybe/utils/dataframe.py +++ b/baybe/utils/dataframe.py @@ -625,6 +625,31 @@ def get_transform_objects( return [p for p in objects if p.name in df] +def transform_target_columns( + df: pd.DataFrame, + targets: Sequence[Target], + /, + *, + allow_missing: bool = False, + allow_extra: bool = False, +) -> pd.DataFrame: + """Transform the columns of a dataframe that correspond to objects of type :class:`~baybe.targets.base.Target`. + + For more details, see :func:`baybe.utils.dataframe.get_transform_objects`. + """ # noqa: E501 + # Extract the relevant part of the dataframe + targets = get_transform_objects( + df, targets, allow_missing=allow_missing, allow_extra=allow_extra + ) + transformed = df[[t.name for t in targets]].copy() + + # Transform all targets individually + for target in targets: + transformed[target.name] = target.transform(df[target.name]) + + return transformed + + def filter_df( df: pd.DataFrame, /, to_keep: pd.DataFrame, complement: bool = False ) -> pd.DataFrame: From 04056ee1159e947b2a2964a964819011efde4ff4 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 5 Dec 2024 13:52:49 +0100 Subject: [PATCH 04/47] Add qLogNEHVI acqusition function --- baybe/acquisition/__init__.py | 6 ++++++ baybe/acquisition/acqfs.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/baybe/acquisition/__init__.py b/baybe/acquisition/__init__.py index 9f2350fbfa..b09d971e28 100644 --- a/baybe/acquisition/__init__.py +++ b/baybe/acquisition/__init__.py @@ -10,6 +10,7 @@ qExpectedImprovement, qKnowledgeGradient, qLogExpectedImprovement, + qLogNoisyExpectedHypervolumeImprovement, qLogNoisyExpectedImprovement, qNegIntegratedPosteriorVariance, qNoisyExpectedImprovement, @@ -37,6 +38,7 @@ UCB = UpperConfidenceBound qUCB = qUpperConfidenceBound qTS = qThompsonSampling +qLogNEHVI = qLogNoisyExpectedHypervolumeImprovement __all__ = [ ######################### Acquisition functions @@ -64,6 +66,8 @@ "qUpperConfidenceBound", # Thompson Sampling "qThompsonSampling", + # Hypervolume Improvement + "qLogNoisyExpectedHypervolumeImprovement", ######################### Abbreviations # Knowledge Gradient "qKG", @@ -89,4 +93,6 @@ "qUCB", # Thompson Sampling "qTS", + # Hypervolume Improvement + "qLogNEHVI", ] diff --git a/baybe/acquisition/acqfs.py b/baybe/acquisition/acqfs.py index ad2fb018f2..1c9c7f2f2c 100644 --- a/baybe/acquisition/acqfs.py +++ b/baybe/acquisition/acqfs.py @@ -4,6 +4,7 @@ import math from typing import ClassVar +import cattrs import pandas as pd from attr.converters import optional as optional_c from attr.validators import optional as optional_v @@ -320,5 +321,19 @@ def supports_batching(cls) -> bool: return False +######################################################################################## +### Hypervolume Improvement +@define(frozen=True) +class qLogNoisyExpectedHypervolumeImprovement(AcquisitionFunction): + """Logarithmic Monte Carlo based noisy expected hypervolume improvement.""" + + abbreviation: ClassVar[str] = "qLogNEHVI" + + ref_point: tuple[float, ...] = field( + converter=lambda x: cattrs.structure(x, tuple[float, ...]) + ) + """The reference point for computing the hypervolume improvement.""" + + # Collect leftover original slotted classes processed by `attrs.define` gc.collect() From 8b2d199c0de4c200e5ad56418b55b857c9ace434 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 5 Dec 2024 13:53:35 +0100 Subject: [PATCH 05/47] Make botorch multiobjective acqusition functions autodetectable --- baybe/acquisition/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/baybe/acquisition/base.py b/baybe/acquisition/base.py index 358d079cf2..a226808d36 100644 --- a/baybe/acquisition/base.py +++ b/baybe/acquisition/base.py @@ -172,7 +172,9 @@ def _get_botorch_acqf_class( import botorch for cls in baybe_acqf_cls.mro(): - if acqf_cls := getattr(botorch.acquisition, cls.__name__, False): + if acqf_cls := getattr(botorch.acquisition, cls.__name__, False) or getattr( + botorch.acquisition.multi_objective, cls.__name__, False + ): if is_abstract(acqf_cls): continue return acqf_cls # type: ignore From 6788abbca94bf9b1e7c3db4ab2ed1f2d7e8a8915 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 5 Dec 2024 13:56:59 +0100 Subject: [PATCH 06/47] Add temporary restriction allowing only MAX targets --- baybe/acquisition/base.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/baybe/acquisition/base.py b/baybe/acquisition/base.py index a226808d36..39d51b4577 100644 --- a/baybe/acquisition/base.py +++ b/baybe/acquisition/base.py @@ -17,6 +17,7 @@ ) from baybe.objectives.base import Objective from baybe.objectives.desirability import DesirabilityObjective +from baybe.objectives.pareto import ParetoObjective from baybe.objectives.single import SingleTargetObjective from baybe.searchspace.core import SearchSpace from baybe.serialization.core import ( @@ -151,6 +152,12 @@ def to_botorch( additional_params["best_f"] = ( bo_surrogate.posterior(train_x).mean.max().item() ) + case ParetoObjective(): + if any(t.mode is not TargetMode.MAX for t in objective.targets): + raise NotImplementedError( + "Pareto optimization currently supports maximization " + "targets only." + ) case _: raise ValueError(f"Unsupported objective type: {objective}") From 1043156b5ca4b77662699fce8148436c3a8e8910 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 5 Dec 2024 16:00:37 +0100 Subject: [PATCH 07/47] Draft example --- examples/Multi_Target/pareto.py | 170 ++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 examples/Multi_Target/pareto.py diff --git a/examples/Multi_Target/pareto.py b/examples/Multi_Target/pareto.py new file mode 100644 index 0000000000..2853d20ece --- /dev/null +++ b/examples/Multi_Target/pareto.py @@ -0,0 +1,170 @@ +## Single-Target vs. Pareto Optimization + +# In this example, we illustrate the difference between single-target and Pareto +# optimization under laboratory conditions provided by a pair of synthetic targets. +# In particular: +# * We set up two root mean square target functions with different center points, +# * visualize the corresponding Pareto frontier, +# * and compare the recommendations obtained by optimizing the targets individually +# and jointly. + + +### Imports + +import numpy as np +import pandas as pd +from matplotlib import pyplot as plt +from polars import set_random_seed + +from baybe.acquisition import qLogNEHVI +from baybe.campaign import Campaign +from baybe.objectives import ParetoObjective +from baybe.parameters import NumericalContinuousParameter +from baybe.recommenders import BotorchRecommender +from baybe.searchspace import SearchSpace +from baybe.targets import NumericalTarget +from baybe.utils.dataframe import arrays_to_dataframes + +### Settings + +# Let's first define some general settings for our example: + +BATCH_SIZE = 10 +N_TRAINING_DATA = 100 +N_GRID_POINTS = 100 +CENTER_Y0 = (-0.5, -0.5) +CENTER_Y1 = (0.5, 0.5) + +# Also, we fix the random seed for reproducibility: + +set_random_seed(1337) + + +### Defining the Optimization Problem + +# We start by defining the parameters and targets describing the inputs and outputs +# of our synthetic functions: + +x0 = NumericalContinuousParameter("x0", (-1, 1)) +x1 = NumericalContinuousParameter("x1", (-1, 1)) +y0 = NumericalTarget("y0", "MAX") +y1 = NumericalTarget("y1", "MAX") +searchspace = SearchSpace.from_product([x0, x1]) + +# With these definitions at hand, we can construct a multi-variate callable representing +# the two quadratic target functions: + + +@arrays_to_dataframes([x0.name, x1.name], [y0.name, y1.name]) +def lookup(arr: np.ndarray) -> np.ndarray: + """Compute root mean square values for different center points.""" + y0 = np.sqrt(np.sum((arr - CENTER_Y0) ** 2, axis=1)) + y1 = np.sqrt(np.sum((arr - CENTER_Y1) ** 2, axis=1)) + return -np.c_[y0, y1] + + +### Campaign Setup + +# We now query the callable with some randomly generated inputs to collect training +# data for our model: + +data = searchspace.continuous.sample_uniform(N_TRAINING_DATA) +data = pd.concat([data, lookup(data)], axis=1) + +# Next, we create three campaigns for comparison: +# * One focusing on the first target +# * One focusing on the second target +# * One for Pareto optimization of both targets + +campaign_y0 = Campaign( + searchspace=searchspace, + objective=y0, + recommender=BotorchRecommender( + acquisition_function="qUCB", sequential_continuous=True + ), +) +campaign_y1 = Campaign( + searchspace=searchspace, + objective=y1, + recommender=BotorchRecommender( + acquisition_function="qUCB", sequential_continuous=True + ), +) + +campaign_par = Campaign( + searchspace=searchspace, + objective=ParetoObjective([y0, y1]), + recommender=BotorchRecommender( + acquisition_function=qLogNEHVI(ref_point=[-1, -1]), + sequential_continuous=True, + ), +) + +# We feed each campaign with the same training data and request recommendations: + +campaign_y0.add_measurements(data) +campaign_y1.add_measurements(data) +campaign_par.add_measurements(data) + +rec_y0 = campaign_y0.recommend(BATCH_SIZE) +rec_y1 = campaign_y1.recommend(BATCH_SIZE) +rec_par = campaign_par.recommend(BATCH_SIZE) + +out_y0 = lookup(rec_y0) +out_y1 = lookup(rec_y1) +out_par = lookup(rec_par) + + +### Visualization + +# To visualize the results, we first create grids to sample our target functions: + +x0_mesh, x1_mesh = np.meshgrid( + np.linspace(*x0.bounds.to_tuple(), N_GRID_POINTS), + np.linspace(*x1.bounds.to_tuple(), N_GRID_POINTS), +) +df_y = lookup(pd.DataFrame({x0.name: x0_mesh.ravel(), x1.name: x1_mesh.ravel()})) +y0_mesh = np.reshape(df_y[y0.name], x0_mesh.shape) +y1_mesh = np.reshape(df_y[y1.name], x0_mesh.shape) + + +# Now, we can plot the function values, the training data, the recommendations, +# and the Pareto frontier in the parameter space: + +fig, axs = plt.subplots(1, 2, figsize=(10, 5)) + +plt.sca(axs[0]) +plt.contour(x0_mesh, x1_mesh, y0_mesh, colors="tab:red", alpha=0.2, label="") +plt.contour(x0_mesh, x1_mesh, y1_mesh, colors="tab:blue", alpha=0.2, label="") +plt.plot(*np.c_[CENTER_Y0, CENTER_Y1], "k", label="frontier") +plt.plot(data[x0.name], data[x1.name], "o", color="0.7", markersize=2, label="training") +plt.plot(rec_y0[x0.name], rec_y0[x1.name], "o", color="tab:red", label="single_y0") +plt.plot(rec_y1[x0.name], rec_y1[x1.name], "o", color="tab:blue", label="single_y1") +plt.plot(rec_par[x0.name], rec_par[x1.name], "o", color="tab:purple", label="pareto") +plt.legend(loc="upper left") +plt.xlabel(x0.name) +plt.ylabel(x1.name) +plt.title("Parameter Space") +plt.axis("square") +plt.axis([-1, 1, -1, 1]) + +# Similarly, we plot the training data, the achieved function values, +# and the Pareto frontier in the target space: + +plt.sca(axs[1]) +centers = lookup( + pd.DataFrame(np.c_[CENTER_Y0, CENTER_Y1].T, columns=[x0.name, x1.name]) +) +plt.plot(*centers.to_numpy().T, "k", label="frontier") +plt.plot(data[y0.name], data[y1.name], "o", color="0.7", markersize=2, label="training") +plt.plot(out_y0[y0.name], out_y0[y1.name], "o", color="tab:red", label="single_y0") +plt.plot(out_y1[y0.name], out_y1[y1.name], "o", color="tab:blue", label="single_y1") +plt.plot(out_par[y0.name], out_par[y1.name], "o", color="tab:purple", label="pareto") +plt.legend(loc="upper right") +plt.xlabel(y0.name) +plt.ylabel(y1.name) +plt.title("Target Space") +plt.axis("square") + +plt.tight_layout() +plt.savefig("pareto.svg") From 473ac158d057eadc53a99b46d238d254ba23eb15 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Fri, 24 Jan 2025 10:47:01 +0100 Subject: [PATCH 08/47] Enable minimization targets --- baybe/acquisition/base.py | 19 ++++++++++++++++--- examples/Multi_Target/pareto.py | 6 +++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/baybe/acquisition/base.py b/baybe/acquisition/base.py index 39d51b4577..0c5e9917d3 100644 --- a/baybe/acquisition/base.py +++ b/baybe/acquisition/base.py @@ -77,6 +77,7 @@ def to_botorch( """ import botorch.acquisition as bo_acqf import torch + from botorch.acquisition.multi_objective import WeightedMCMultiOutputObjective from botorch.acquisition.objective import LinearMCObjective from baybe.acquisition.acqfs import qThompsonSampling @@ -153,11 +154,23 @@ def to_botorch( bo_surrogate.posterior(train_x).mean.max().item() ) case ParetoObjective(): - if any(t.mode is not TargetMode.MAX for t in objective.targets): + if not all( + isinstance(t, NumericalTarget) + and t.mode in (TargetMode.MAX, TargetMode.MIN) + for t in objective.targets + ): raise NotImplementedError( - "Pareto optimization currently supports maximization " - "targets only." + "Pareto optimization currently supports " + "maximization/minimization targets only." + ) + additional_params["objective"] = WeightedMCMultiOutputObjective( + torch.tensor( + [ + 1.0 if t.mode is TargetMode.MAX else -1.0 # type: ignore[attr-defined] + for t in objective.targets + ] ) + ) case _: raise ValueError(f"Unsupported objective type: {objective}") diff --git a/examples/Multi_Target/pareto.py b/examples/Multi_Target/pareto.py index 2853d20ece..06cd09a7eb 100644 --- a/examples/Multi_Target/pareto.py +++ b/examples/Multi_Target/pareto.py @@ -47,8 +47,8 @@ x0 = NumericalContinuousParameter("x0", (-1, 1)) x1 = NumericalContinuousParameter("x1", (-1, 1)) -y0 = NumericalTarget("y0", "MAX") -y1 = NumericalTarget("y1", "MAX") +y0 = NumericalTarget("y0", "MIN") +y1 = NumericalTarget("y1", "MIN") searchspace = SearchSpace.from_product([x0, x1]) # With these definitions at hand, we can construct a multi-variate callable representing @@ -60,7 +60,7 @@ def lookup(arr: np.ndarray) -> np.ndarray: """Compute root mean square values for different center points.""" y0 = np.sqrt(np.sum((arr - CENTER_Y0) ** 2, axis=1)) y1 = np.sqrt(np.sum((arr - CENTER_Y1) ** 2, axis=1)) - return -np.c_[y0, y1] + return np.c_[y0, y1] ### Campaign Setup From 41542f15f9794de92919e3687297f840db7510b1 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Fri, 24 Jan 2025 10:58:17 +0100 Subject: [PATCH 09/47] Add highlighted feature to README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b62800c34d..8bea68a52b 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ The following provides a non-comprehensive overview: - 🛠️ Custom parameter encodings: Improve your campaign with domain knowledge - 🧪 Built-in chemical encodings: Improve your campaign with chemical knowledge -- 🎯 Single and multiple targets with min, max and match objectives +- 🎯 Numerical and binary targets with min, max and match objectives +- ⚖️ Multi-target support via Pareto optimization and desirability scalarization - 🔍 Insights: Easily analyze feature importance and model behavior - 🎭 Hybrid (mixed continuous and discrete) spaces - 🚀 Transfer learning: Mix data from multiple campaigns and accelerate optimization From d0d77164f62d8a9e4ec38c8dca256918c78d11f6 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Fri, 24 Jan 2025 11:13:24 +0100 Subject: [PATCH 10/47] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e02de8c5c8..4ca332abba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `BCUT2D` encoding for `SubstanceParameter` - Stored benchmarking results now include the Python environment and version - `qPSTD` acquisition function +- `ParetoObjective` class for Pareto optimization of multiple targets and corresponding + `qLogNoisyExpectedHypervolumeImprovement` acquisition function ### Changed - Acquisition function indicator `is_mc` has been removed in favor of new indicators From 75be53417748a445da804d1ab908511a3ea0f62d Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 3 Feb 2025 12:24:30 +0100 Subject: [PATCH 11/47] Compute default reference point from data --- baybe/acquisition/acqfs.py | 43 +++++++++++++++++++++++++++++---- baybe/acquisition/base.py | 22 +++++++++++------ baybe/utils/basic.py | 17 ++++++++++++- examples/Multi_Target/pareto.py | 2 +- 4 files changed, 70 insertions(+), 14 deletions(-) diff --git a/baybe/acquisition/acqfs.py b/baybe/acquisition/acqfs.py index 1c9c7f2f2c..1b613290db 100644 --- a/baybe/acquisition/acqfs.py +++ b/baybe/acquisition/acqfs.py @@ -4,7 +4,8 @@ import math from typing import ClassVar -import cattrs +import numpy as np +import numpy.typing as npt import pandas as pd from attr.converters import optional as optional_c from attr.validators import optional as optional_v @@ -14,7 +15,7 @@ from baybe.acquisition.base import AcquisitionFunction from baybe.searchspace import SearchSpace -from baybe.utils.basic import classproperty +from baybe.utils.basic import classproperty, convert_to_float from baybe.utils.sampling_algorithms import ( DiscreteSamplingMethod, sample_numerical_df, @@ -329,10 +330,42 @@ class qLogNoisyExpectedHypervolumeImprovement(AcquisitionFunction): abbreviation: ClassVar[str] = "qLogNEHVI" - ref_point: tuple[float, ...] = field( - converter=lambda x: cattrs.structure(x, tuple[float, ...]) + ref_point: float | tuple[float, ...] | None = field( + default=None, converter=optional_c(convert_to_float) ) - """The reference point for computing the hypervolume improvement.""" + """The reference point for computing the hypervolume improvement. + + * When omitted, a default reference point is computed based on the provided data. + * When specified as a float, the value is interpreted as a multiplicative factor + determining the reference point location based on the difference between the best + and worst target configuration in the provided data. + * When specified as a vector, the input is taken as is. + """ + + @staticmethod + def compute_ref_point( + array: npt.ArrayLike, maximize: npt.ArrayLike, factor: float = 0.1 + ) -> npt.NDArray: + """Compute the reference point based on the observed target configurations.""" + if np.ndim(array) != 2: + raise ValueError( + "The specified data array must have exactly two dimensions." + ) + if np.ndim(maximize) != 1: + raise ValueError( + "The specified Boolean array must have exactly one dimension." + ) + + # Convert arrays + array = np.asarray(array) + maximize = np.where(maximize, 1.0, -1.0) + + # Compute bounds + array = array * maximize[None, :] + min = np.min(array, axis=0) + max = np.max(array, axis=0) + + return min - factor * (max - min) # Collect leftover original slotted classes processed by `attrs.define` diff --git a/baybe/acquisition/base.py b/baybe/acquisition/base.py index 0c5e9917d3..27d17c63c9 100644 --- a/baybe/acquisition/base.py +++ b/baybe/acquisition/base.py @@ -5,6 +5,7 @@ import gc import warnings from abc import ABC +from collections.abc import Iterable from inspect import signature from typing import TYPE_CHECKING, ClassVar @@ -80,7 +81,10 @@ def to_botorch( from botorch.acquisition.multi_objective import WeightedMCMultiOutputObjective from botorch.acquisition.objective import LinearMCObjective - from baybe.acquisition.acqfs import qThompsonSampling + from baybe.acquisition.acqfs import ( + qLogNoisyExpectedHypervolumeImprovement, + qThompsonSampling, + ) # Retrieve botorch acquisition function class and match attributes acqf_cls = _get_botorch_acqf_class(type(self)) @@ -154,6 +158,7 @@ def to_botorch( bo_surrogate.posterior(train_x).mean.max().item() ) case ParetoObjective(): + assert isinstance(self, qLogNoisyExpectedHypervolumeImprovement) if not all( isinstance(t, NumericalTarget) and t.mode in (TargetMode.MAX, TargetMode.MIN) @@ -163,14 +168,17 @@ def to_botorch( "Pareto optimization currently supports " "maximization/minimization targets only." ) + maximize = [t.mode == TargetMode.MAX for t in objective.targets] # type: ignore[attr-defined] additional_params["objective"] = WeightedMCMultiOutputObjective( - torch.tensor( - [ - 1.0 if t.mode is TargetMode.MAX else -1.0 # type: ignore[attr-defined] - for t in objective.targets - ] - ) + torch.tensor([1.0 if m else -1.0 for m in maximize]) ) + train_y = measurements[[t.name for t in objective.targets]].to_numpy() + if not isinstance(ref_point := params_dict["ref_point"], Iterable): + kwargs = {"factor": ref_point} if ref_point is not None else {} + params_dict["ref_point"] = self.compute_ref_point( + train_y, maximize, **kwargs + ) + case _: raise ValueError(f"Unsupported objective type: {objective}") diff --git a/baybe/utils/basic.py b/baybe/utils/basic.py index 06c18d3647..2aff2095a8 100644 --- a/baybe/utils/basic.py +++ b/baybe/utils/basic.py @@ -1,17 +1,23 @@ """Collection of small basic utilities.""" +from __future__ import annotations + import enum import functools import inspect from collections.abc import Callable, Collection, Iterable, Sequence from dataclasses import dataclass -from typing import Any, TypeGuard, TypeVar +from typing import TYPE_CHECKING, Any, TypeGuard, TypeVar +import cattrs from attrs import asdict, has from typing_extensions import override from baybe.exceptions import UnidentifiedSubclassError, UnmatchedAttributeError +if TYPE_CHECKING: + from _typeshed import ConvertibleToFloat + _C = TypeVar("_C", bound=type) _T = TypeVar("_T") _U = TypeVar("_U") @@ -338,3 +344,12 @@ def wraps(*args, **kwargs): return result return wraps + + +def convert_to_float( + x: ConvertibleToFloat | Iterable[ConvertibleToFloat], +) -> float | tuple[float, ...]: + """Convert to a float / iterable of floats.""" + if isinstance(x, Iterable): + return cattrs.structure(x, tuple[float, ...]) + return float(x) diff --git a/examples/Multi_Target/pareto.py b/examples/Multi_Target/pareto.py index 06cd09a7eb..de75b00506 100644 --- a/examples/Multi_Target/pareto.py +++ b/examples/Multi_Target/pareto.py @@ -95,7 +95,7 @@ def lookup(arr: np.ndarray) -> np.ndarray: searchspace=searchspace, objective=ParetoObjective([y0, y1]), recommender=BotorchRecommender( - acquisition_function=qLogNEHVI(ref_point=[-1, -1]), + acquisition_function=qLogNEHVI(), sequential_continuous=True, ), ) From 0e340e5d12a22fb6f71cd8ab2b35ed77dc263701 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 3 Feb 2025 12:55:42 +0100 Subject: [PATCH 12/47] Flip signs of custom reference points in MIN mode --- baybe/acquisition/base.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/baybe/acquisition/base.py b/baybe/acquisition/base.py index 27d17c63c9..729127b303 100644 --- a/baybe/acquisition/base.py +++ b/baybe/acquisition/base.py @@ -169,15 +169,19 @@ def to_botorch( "maximization/minimization targets only." ) maximize = [t.mode == TargetMode.MAX for t in objective.targets] # type: ignore[attr-defined] + multiplier = torch.tensor([1.0 if m else -1.0 for m in maximize]) additional_params["objective"] = WeightedMCMultiOutputObjective( - torch.tensor([1.0 if m else -1.0 for m in maximize]) + multiplier ) train_y = measurements[[t.name for t in objective.targets]].to_numpy() - if not isinstance(ref_point := params_dict["ref_point"], Iterable): + if isinstance(ref_point := params_dict["ref_point"], Iterable): + ref_point = [ + p * m for p, m in zip(ref_point, multiplier, strict=True) + ] + else: kwargs = {"factor": ref_point} if ref_point is not None else {} - params_dict["ref_point"] = self.compute_ref_point( - train_y, maximize, **kwargs - ) + ref_point = self.compute_ref_point(train_y, maximize, **kwargs) + params_dict["ref_point"] = ref_point case _: raise ValueError(f"Unsupported objective type: {objective}") From 12eef752ca09e226cb3bbc046adbca989482cd3f Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 3 Feb 2025 13:02:25 +0100 Subject: [PATCH 13/47] Interpolate target paretor frontier along transformed points --- examples/Multi_Target/pareto.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/Multi_Target/pareto.py b/examples/Multi_Target/pareto.py index de75b00506..5a59590fb6 100644 --- a/examples/Multi_Target/pareto.py +++ b/examples/Multi_Target/pareto.py @@ -153,7 +153,7 @@ def lookup(arr: np.ndarray) -> np.ndarray: plt.sca(axs[1]) centers = lookup( - pd.DataFrame(np.c_[CENTER_Y0, CENTER_Y1].T, columns=[x0.name, x1.name]) + pd.DataFrame(np.linspace(CENTER_Y0, CENTER_Y1), columns=[x0.name, x1.name]) ) plt.plot(*centers.to_numpy().T, "k", label="frontier") plt.plot(data[y0.name], data[y1.name], "o", color="0.7", markersize=2, label="training") From 0f29bda53cf4c59226c7d4b612ad546a3d327e9c Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 3 Feb 2025 13:20:10 +0100 Subject: [PATCH 14/47] Drop unnecessary label arguments --- examples/Multi_Target/pareto.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/Multi_Target/pareto.py b/examples/Multi_Target/pareto.py index 5a59590fb6..8fe0a73b67 100644 --- a/examples/Multi_Target/pareto.py +++ b/examples/Multi_Target/pareto.py @@ -134,8 +134,8 @@ def lookup(arr: np.ndarray) -> np.ndarray: fig, axs = plt.subplots(1, 2, figsize=(10, 5)) plt.sca(axs[0]) -plt.contour(x0_mesh, x1_mesh, y0_mesh, colors="tab:red", alpha=0.2, label="") -plt.contour(x0_mesh, x1_mesh, y1_mesh, colors="tab:blue", alpha=0.2, label="") +plt.contour(x0_mesh, x1_mesh, y0_mesh, colors="tab:red", alpha=0.2) +plt.contour(x0_mesh, x1_mesh, y1_mesh, colors="tab:blue", alpha=0.2) plt.plot(*np.c_[CENTER_Y0, CENTER_Y1], "k", label="frontier") plt.plot(data[x0.name], data[x1.name], "o", color="0.7", markersize=2, label="training") plt.plot(rec_y0[x0.name], rec_y0[x1.name], "o", color="tab:red", label="single_y0") From 1081ce7d79a1cd0cc3550a6620adba847a510a18 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 3 Feb 2025 15:03:36 +0100 Subject: [PATCH 15/47] Drop square root from target functions --- examples/Multi_Target/pareto.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/Multi_Target/pareto.py b/examples/Multi_Target/pareto.py index 8fe0a73b67..50e5c38449 100644 --- a/examples/Multi_Target/pareto.py +++ b/examples/Multi_Target/pareto.py @@ -3,7 +3,7 @@ # In this example, we illustrate the difference between single-target and Pareto # optimization under laboratory conditions provided by a pair of synthetic targets. # In particular: -# * We set up two root mean square target functions with different center points, +# * We set up two quadratic target functions with different center points, # * visualize the corresponding Pareto frontier, # * and compare the recommendations obtained by optimizing the targets individually # and jointly. @@ -58,8 +58,8 @@ @arrays_to_dataframes([x0.name, x1.name], [y0.name, y1.name]) def lookup(arr: np.ndarray) -> np.ndarray: """Compute root mean square values for different center points.""" - y0 = np.sqrt(np.sum((arr - CENTER_Y0) ** 2, axis=1)) - y1 = np.sqrt(np.sum((arr - CENTER_Y1) ** 2, axis=1)) + y0 = np.sum((arr - CENTER_Y0) ** 2, axis=1) + y1 = np.sum((arr - CENTER_Y1) ** 2, axis=1) return np.c_[y0, y1] From 262392fc08bc27a3b2416cd017fa76b6eeb0f8e3 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 3 Feb 2025 15:07:43 +0100 Subject: [PATCH 16/47] Mention ParetoObjective in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8bea68a52b..8384a797eb 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,8 @@ target = NumericalTarget( objective = SingleTargetObjective(target=target) ``` In cases where we are confronted with multiple (potentially conflicting) targets, -the `DesirabilityObjective` can be used instead. It allows to define additional -settings, such as how these targets should be balanced. +the `ParetoObjective` or `DesirabilityObjective` can be used instead. +These allow to define additional settings, such as how the targets should be balanced. For more details, see the [objectives section](https://emdgroup.github.io/baybe/stable/userguide/objectives.html) of the user guide. From d9d8541d3c0d03cc8d96cd0d44bd3252a660903d Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Wed, 12 Feb 2025 10:42:58 +0100 Subject: [PATCH 17/47] Fix enum comparison operator Co-authored-by: Martin Fitzner <17951239+Scienfitz@users.noreply.github.com> --- baybe/acquisition/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/baybe/acquisition/base.py b/baybe/acquisition/base.py index 729127b303..4852e1698b 100644 --- a/baybe/acquisition/base.py +++ b/baybe/acquisition/base.py @@ -168,7 +168,7 @@ def to_botorch( "Pareto optimization currently supports " "maximization/minimization targets only." ) - maximize = [t.mode == TargetMode.MAX for t in objective.targets] # type: ignore[attr-defined] + maximize = [t.mode is TargetMode.MAX for t in objective.targets] # type: ignore[attr-defined] multiplier = torch.tensor([1.0 if m else -1.0 for m in maximize]) additional_params["objective"] = WeightedMCMultiOutputObjective( multiplier From 42273de0fb9962ea24f8696f5c68bc5d220140ac Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Wed, 12 Feb 2025 10:44:00 +0100 Subject: [PATCH 18/47] Drop duplicate override decorator --- baybe/objectives/pareto.py | 1 - 1 file changed, 1 deletion(-) diff --git a/baybe/objectives/pareto.py b/baybe/objectives/pareto.py index 21cd0b4074..4b2386a6ef 100644 --- a/baybe/objectives/pareto.py +++ b/baybe/objectives/pareto.py @@ -29,7 +29,6 @@ class ParetoObjective(Objective): def targets(self) -> tuple[Target, ...]: return self._targets - @override @override def transform( self, From 44b605ddb79aaffe36ec68c8733bec070c25e4a5 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Wed, 12 Feb 2025 10:51:37 +0100 Subject: [PATCH 19/47] Fix random seed utility import --- examples/Multi_Target/pareto.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/Multi_Target/pareto.py b/examples/Multi_Target/pareto.py index 50e5c38449..622de03359 100644 --- a/examples/Multi_Target/pareto.py +++ b/examples/Multi_Target/pareto.py @@ -14,7 +14,6 @@ import numpy as np import pandas as pd from matplotlib import pyplot as plt -from polars import set_random_seed from baybe.acquisition import qLogNEHVI from baybe.campaign import Campaign @@ -24,6 +23,7 @@ from baybe.searchspace import SearchSpace from baybe.targets import NumericalTarget from baybe.utils.dataframe import arrays_to_dataframes +from baybe.utils.random import set_random_seed ### Settings From b9bc60c3d9373899604eb931b3dfc6398b266784 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Wed, 12 Feb 2025 14:40:58 +0100 Subject: [PATCH 20/47] Explicitly convert targets to objectives --- examples/Multi_Target/pareto.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/Multi_Target/pareto.py b/examples/Multi_Target/pareto.py index 622de03359..f1e44e170c 100644 --- a/examples/Multi_Target/pareto.py +++ b/examples/Multi_Target/pareto.py @@ -78,14 +78,14 @@ def lookup(arr: np.ndarray) -> np.ndarray: campaign_y0 = Campaign( searchspace=searchspace, - objective=y0, + objective=y0.to_objective(), recommender=BotorchRecommender( acquisition_function="qUCB", sequential_continuous=True ), ) campaign_y1 = Campaign( searchspace=searchspace, - objective=y1, + objective=y1.to_objective(), recommender=BotorchRecommender( acquisition_function="qUCB", sequential_continuous=True ), From 7b97131e88ecbe84d9696e09aee0c374a9915453 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 13 Feb 2025 09:19:34 +0100 Subject: [PATCH 21/47] Dynamically select default acquisition function --- baybe/recommenders/pure/bayesian/base.py | 20 +++++++++++++++----- baybe/recommenders/pure/bayesian/botorch.py | 15 ++++++++++----- examples/Multi_Target/pareto.py | 6 +----- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/baybe/recommenders/pure/bayesian/base.py b/baybe/recommenders/pure/bayesian/base.py index 2ceef3b427..1b30bb93ce 100644 --- a/baybe/recommenders/pure/bayesian/base.py +++ b/baybe/recommenders/pure/bayesian/base.py @@ -6,9 +6,10 @@ import pandas as pd from attrs import define, field, fields +from attrs.converters import optional from typing_extensions import override -from baybe.acquisition.acqfs import qLogExpectedImprovement +from baybe.acquisition import qLogEI, qLogNEHVI from baybe.acquisition.base import AcquisitionFunction from baybe.acquisition.utils import convert_acqf from baybe.exceptions import DeprecationError, InvalidSurrogateModelError @@ -30,10 +31,13 @@ class BayesianRecommender(PureRecommender, ABC): ) """The used surrogate model.""" - acquisition_function: AcquisitionFunction = field( - converter=convert_acqf, factory=qLogExpectedImprovement + acquisition_function: AcquisitionFunction | None = field( + default=None, converter=optional(convert_acqf) ) - """The used acquisition function class.""" + """The user-specified acquisition function. When omitted, a default is used.""" + + _acqf: AcquisitionFunction | None = field(default=None, init=False) + """The used acquisition function.""" _botorch_acqf = field(default=None, init=False) """The current acquisition function.""" @@ -62,6 +66,11 @@ def surrogate_model(self) -> SurrogateProtocol: ) return self._surrogate_model + @staticmethod + def _default_acquisition_function(objective: Objective) -> AcquisitionFunction: + """Select the appropriate default acquisition function for the given context.""" + return qLogEI() if len(objective.targets) == 1 else qLogNEHVI() + def get_surrogate( self, searchspace: SearchSpace, @@ -83,7 +92,8 @@ def _setup_botorch_acqf( ) -> None: """Create the acquisition function for the current training data.""" # noqa: E501 surrogate = self.get_surrogate(searchspace, objective, measurements) - self._botorch_acqf = self.acquisition_function.to_botorch( + self._acqf = self._default_acquisition_function(objective) + self._botorch_acqf = self._acqf.to_botorch( surrogate, searchspace, objective, diff --git a/baybe/recommenders/pure/bayesian/botorch.py b/baybe/recommenders/pure/bayesian/botorch.py index ab550e9b4a..b52065f591 100644 --- a/baybe/recommenders/pure/bayesian/botorch.py +++ b/baybe/recommenders/pure/bayesian/botorch.py @@ -114,12 +114,14 @@ def _recommend_discrete( The dataframe indices of the recommended points in the provided experimental representation. """ - if batch_size > 1 and not self.acquisition_function.supports_batching: + assert self._acqf is not None + + if batch_size > 1 and not self._acqf.supports_batching: raise IncompatibleAcquisitionFunctionError( f"The '{self.__class__.__name__}' only works with Monte Carlo " f"acquisition functions for batch sizes > 1." ) - if batch_size > 1 and isinstance(self.acquisition_function, qThompsonSampling): + if batch_size > 1 and isinstance(self._acqf, qThompsonSampling): raise IncompatibilityError( "Thompson sampling currently only supports a batch size of 1." ) @@ -168,7 +170,9 @@ def _recommend_continuous( Returns: A dataframe containing the recommendations as individual rows. """ - if batch_size > 1 and not self.acquisition_function.supports_batching: + assert self._acqf is not None + + if batch_size > 1 and not self._acqf.supports_batching: raise IncompatibleAcquisitionFunctionError( f"The '{self.__class__.__name__}' only works with Monte Carlo " f"acquisition functions for batch sizes > 1." @@ -233,8 +237,9 @@ def _recommend_hybrid( Returns: The recommended points. """ - # For batch size > 1, the acqf needs to support batching - if batch_size > 1 and not self.acquisition_function.supports_batching: + assert self._acqf is not None + + if batch_size > 1 and not self._acqf.supports_batching: raise IncompatibleAcquisitionFunctionError( f"The '{self.__class__.__name__}' only works with Monte Carlo " f"acquisition functions for batch sizes > 1." diff --git a/examples/Multi_Target/pareto.py b/examples/Multi_Target/pareto.py index f1e44e170c..44618fd1d7 100644 --- a/examples/Multi_Target/pareto.py +++ b/examples/Multi_Target/pareto.py @@ -15,7 +15,6 @@ import pandas as pd from matplotlib import pyplot as plt -from baybe.acquisition import qLogNEHVI from baybe.campaign import Campaign from baybe.objectives import ParetoObjective from baybe.parameters import NumericalContinuousParameter @@ -94,10 +93,7 @@ def lookup(arr: np.ndarray) -> np.ndarray: campaign_par = Campaign( searchspace=searchspace, objective=ParetoObjective([y0, y1]), - recommender=BotorchRecommender( - acquisition_function=qLogNEHVI(), - sequential_continuous=True, - ), + recommender=BotorchRecommender(sequential_continuous=True), ) # We feed each campaign with the same training data and request recommendations: From eb6aad696a0eaa54555ccca552ecb4def37b6763 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 13 Feb 2025 09:21:03 +0100 Subject: [PATCH 22/47] Deactivate comparison for non-persistent attributes --- baybe/recommenders/pure/bayesian/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/baybe/recommenders/pure/bayesian/base.py b/baybe/recommenders/pure/bayesian/base.py index 1b30bb93ce..13f08fa146 100644 --- a/baybe/recommenders/pure/bayesian/base.py +++ b/baybe/recommenders/pure/bayesian/base.py @@ -36,10 +36,10 @@ class BayesianRecommender(PureRecommender, ABC): ) """The user-specified acquisition function. When omitted, a default is used.""" - _acqf: AcquisitionFunction | None = field(default=None, init=False) + _acqf: AcquisitionFunction | None = field(default=None, init=False, eq=False) """The used acquisition function.""" - _botorch_acqf = field(default=None, init=False) + _botorch_acqf = field(default=None, init=False, eq=False) """The current acquisition function.""" acquisition_function_cls: str | None = field(default=None, kw_only=True) From 9cf8363a665593c5e0921fc20a4b70be2741429c Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 13 Feb 2025 09:26:02 +0100 Subject: [PATCH 23/47] Fix variable reference in example Co-authored-by: Martin Fitzner <17951239+Scienfitz@users.noreply.github.com> --- examples/Multi_Target/pareto.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/Multi_Target/pareto.py b/examples/Multi_Target/pareto.py index 44618fd1d7..b83d904939 100644 --- a/examples/Multi_Target/pareto.py +++ b/examples/Multi_Target/pareto.py @@ -121,7 +121,7 @@ def lookup(arr: np.ndarray) -> np.ndarray: ) df_y = lookup(pd.DataFrame({x0.name: x0_mesh.ravel(), x1.name: x1_mesh.ravel()})) y0_mesh = np.reshape(df_y[y0.name], x0_mesh.shape) -y1_mesh = np.reshape(df_y[y1.name], x0_mesh.shape) +y1_mesh = np.reshape(df_y[y1.name], x1_mesh.shape) # Now, we can plot the function values, the training data, the recommendations, From 5795a4acb7c5f0b76b696fc77f7af7865601d7cf Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 13 Feb 2025 09:47:17 +0100 Subject: [PATCH 24/47] Turn assert statement into proper exception --- baybe/acquisition/base.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/baybe/acquisition/base.py b/baybe/acquisition/base.py index 4852e1698b..d3d6d0a83e 100644 --- a/baybe/acquisition/base.py +++ b/baybe/acquisition/base.py @@ -158,7 +158,12 @@ def to_botorch( bo_surrogate.posterior(train_x).mean.max().item() ) case ParetoObjective(): - assert isinstance(self, qLogNoisyExpectedHypervolumeImprovement) + if not isinstance(self, qLogNoisyExpectedHypervolumeImprovement): + raise IncompatibleAcquisitionFunctionError( + f"Pareto optimization currently supports the " + f"'{qLogNoisyExpectedHypervolumeImprovement.__name__}' " + f"acquisition function only." + ) if not all( isinstance(t, NumericalTarget) and t.mode in (TargetMode.MAX, TargetMode.MIN) From f4384bd8aa266a0b6625325acfe4839344e27faf Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 13 Feb 2025 09:58:24 +0100 Subject: [PATCH 25/47] Add prune_baseline attribute --- baybe/acquisition/acqfs.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/baybe/acquisition/acqfs.py b/baybe/acquisition/acqfs.py index 1b613290db..a60fc0acb9 100644 --- a/baybe/acquisition/acqfs.py +++ b/baybe/acquisition/acqfs.py @@ -342,6 +342,9 @@ class qLogNoisyExpectedHypervolumeImprovement(AcquisitionFunction): * When specified as a vector, the input is taken as is. """ + prune_baseline: bool = field(default=True, validator=instance_of(bool)) + """Auto-prune candidates that are unlikely to be the best.""" + @staticmethod def compute_ref_point( array: npt.ArrayLike, maximize: npt.ArrayLike, factor: float = 0.1 From 1fd25e940be2473a40811de1789b5ebd195be737 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 13 Feb 2025 10:19:52 +0100 Subject: [PATCH 26/47] Add full docstring to compute_ref_point --- baybe/acquisition/acqfs.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/baybe/acquisition/acqfs.py b/baybe/acquisition/acqfs.py index a60fc0acb9..61a1753d35 100644 --- a/baybe/acquisition/acqfs.py +++ b/baybe/acquisition/acqfs.py @@ -348,8 +348,36 @@ class qLogNoisyExpectedHypervolumeImprovement(AcquisitionFunction): @staticmethod def compute_ref_point( array: npt.ArrayLike, maximize: npt.ArrayLike, factor: float = 0.1 - ) -> npt.NDArray: - """Compute the reference point based on the observed target configurations.""" + ) -> np.ndarray: + """Compute a reference point for a given set of of target configurations. + + The reference point is positioned in relation to the worst target configuration + within the provided array. The distance in each target dimension is adjusted by + a specified multiplication factor, which scales the reference point away from + the worst target configuration based on the maximum observed differences in + target values. + + Example: + >>> from baybe.acquisition import qLogNEHVI + + >>> qLogNEHVI.compute_ref_point([[0, 10], [2, 20]], [True, True], 0.1) + array([-0.2, 9. ]) + + >>> qLogNEHVI.compute_ref_point([[0, 10], [2, 20]], [True, False], 0.2) + array([ -0.4, -22. ]) + + Args: + array: A 2-D array-like where each row represents a target configuration. + maximize: A 1-D boolean array indicating which targets are to be maximized. + factor: A numeric value controlling the location of the reference point. + + Raises: + ValueError: If the given target configuration array is not two-dimensional. + ValueError: If the given Boolean array is not one-dimensional. + + Returns: + The computed reference point. + """ if np.ndim(array) != 2: raise ValueError( "The specified data array must have exactly two dimensions." From 0c605b8ce41fb078c6ffa7a99a01987b8efea69f Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 13 Feb 2025 10:28:23 +0100 Subject: [PATCH 27/47] Refactor ref_point computation logic The ref_point is now in the original target space so that the user can intuitively specify its coordinates. Sign flips for minimization targets happen behind the scenes. --- baybe/acquisition/acqfs.py | 4 ++-- baybe/acquisition/base.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/baybe/acquisition/acqfs.py b/baybe/acquisition/acqfs.py index 61a1753d35..d0f081187a 100644 --- a/baybe/acquisition/acqfs.py +++ b/baybe/acquisition/acqfs.py @@ -364,7 +364,7 @@ def compute_ref_point( array([-0.2, 9. ]) >>> qLogNEHVI.compute_ref_point([[0, 10], [2, 20]], [True, False], 0.2) - array([ -0.4, -22. ]) + array([ -0.4, 22. ]) Args: array: A 2-D array-like where each row represents a target configuration. @@ -396,7 +396,7 @@ def compute_ref_point( min = np.min(array, axis=0) max = np.max(array, axis=0) - return min - factor * (max - min) + return (min - factor * (max - min)) * maximize # Collect leftover original slotted classes processed by `attrs.define` diff --git a/baybe/acquisition/base.py b/baybe/acquisition/base.py index d3d6d0a83e..19ec44567b 100644 --- a/baybe/acquisition/base.py +++ b/baybe/acquisition/base.py @@ -174,9 +174,9 @@ def to_botorch( "maximization/minimization targets only." ) maximize = [t.mode is TargetMode.MAX for t in objective.targets] # type: ignore[attr-defined] - multiplier = torch.tensor([1.0 if m else -1.0 for m in maximize]) + multiplier = [1.0 if m else -1.0 for m in maximize] additional_params["objective"] = WeightedMCMultiOutputObjective( - multiplier + torch.tensor(multiplier) ) train_y = measurements[[t.name for t in objective.targets]].to_numpy() if isinstance(ref_point := params_dict["ref_point"], Iterable): @@ -185,7 +185,9 @@ def to_botorch( ] else: kwargs = {"factor": ref_point} if ref_point is not None else {} - ref_point = self.compute_ref_point(train_y, maximize, **kwargs) + ref_point = ( + self.compute_ref_point(train_y, maximize, **kwargs) * multiplier + ) params_dict["ref_point"] = ref_point case _: From 81bf88c3206d3ea54bcaa29f565be6b309e30f9c Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 13 Feb 2025 12:45:38 +0100 Subject: [PATCH 28/47] Let doc generation append regular image when available --- docs/scripts/build_examples.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/scripts/build_examples.py b/docs/scripts/build_examples.py index 04b68e815f..f39a786131 100644 --- a/docs/scripts/build_examples.py +++ b/docs/scripts/build_examples.py @@ -171,8 +171,11 @@ def build_examples(destination_directory: Path, dummy: bool, remove_dir: bool): # lines = [line for line in lines if "![svg]" not in line] # We check whether pre-built light and dark plots exist. If so, we append # corresponding lines to our markdown file for including them. + # If not, we check if a single plot version exists and append it + # regardless of light/dark mode. light_figure = Path(sub_directory / (file_name + "_light.svg")) dark_figure = Path(sub_directory / (file_name + "_dark.svg")) + figure = Path(sub_directory / (file_name + ".svg")) if light_figure.is_file() and dark_figure.is_file(): lines.append(f"```{{image}} {file_name}_light.svg\n") lines.append(":align: center\n") @@ -182,6 +185,10 @@ def build_examples(destination_directory: Path, dummy: bool, remove_dir: bool): lines.append(":align: center\n") lines.append(":class: only-dark\n") lines.append("```\n") + elif figure.is_file(): + lines.append(f"```{{image}} {file_name}.svg\n") + lines.append(":align: center\n") + lines.append("```\n") # Rewrite the file with open(markdown_path, "w", encoding="UTF-8") as markdown_file: From f3e5c571bd6237fabea2e61fec73db4e7f713f0a Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 13 Feb 2025 14:26:14 +0100 Subject: [PATCH 29/47] Add ParetoObjective user guide section --- docs/userguide/objectives.md | 51 ++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/docs/userguide/objectives.md b/docs/userguide/objectives.md index c6f6cb162d..761fa72adb 100644 --- a/docs/userguide/objectives.md +++ b/docs/userguide/objectives.md @@ -1,14 +1,10 @@ # Objective Optimization problems involve either a single target quantity of interest or -several (potentially conflicting) targets that need to be considered simultaneously. +several (potentially conflicting) targets that need to be considered simultaneously. BayBE uses the concept of an [`Objective`](baybe.objective.Objective) to allow the user to control how these different types of scenarios are handled. -```{note} -We are actively working on adding more objective types for multiple targets. -``` - ## SingleTargetObjective The need to optimize a single [`Target`](baybe.targets.base.Target) is the most basic type of situation one can encounter in experimental design. @@ -23,8 +19,9 @@ target = NumericalTarget(name="Yield", mode="MAX") objective = SingleTargetObjective(target) ``` In fact, the role of the -`SingleTargetObjective` is to merely signal the absence of other `Target`s in the -optimization problem. +[`SingleTargetObjective`](baybe.objectives.single.SingleTargetObjective) +is to merely signal the absence of other [`Targets`](baybe.targets.base.Target) +in the optimization problem. Because this fairly trivial conversion step requires no additional user configuration, we provide a convenience constructor for it: @@ -62,7 +59,8 @@ If provided, the necessary normalization is taken care of automatically. Otherwise, an error will be thrown. ``` -Besides the list of `targets` to be scalarized, this objective type takes two +Besides the list of [`Targets`](baybe.targets.base.Target) +to be scalarized, this objective type takes two additional optional parameters that let us control its behavior: * `weights`: Specifies the relative importance of the targets in the form of a sequence of positive numbers, one for each target considered. @@ -102,3 +100,40 @@ objective = DesirabilityObjective( ``` For a complete example demonstrating desirability mode, see [here](./../../examples/Multi_Target/desirability). + +## ParetoObjective +The [`ParetoObjective`](baybe.objectives.pareto.ParetoObjective) can be used when the +goal is to find a set of solutions that represent optimal trade-offs among +multiple conflicting targets. Unlike the +[`DesirabilityObjective`](#DesirabilityObjective), this approach does not aggregate the +targets into a single scalar value but instead seeks to identify the Pareto front – the +set of *non-dominated* target configurations. + +```{admonition} Non-dominated Configurations +:class: tip +A target configuration is considered non-dominated if no other configuration is better +in *all* targets. +``` + +Identifying the Pareto front requires maintaining explicit models for each of the target +involved. This differs from the [`DesirabilityObjective`](#DesirabilityObjective), +which relies on a single predictive model to describe the associated +desirability values. However, the drawback of the latter is that the exact trade-off +between the targets must be specified *in advance*, through explicit target +weights. By contrast, the Pareto approach allows to specify this trade-off +*after* the experiments have been carried out, giving the user the flexibly to adjust +their preferences post-hoc – knowing that each of the obtained points is optimal +with respect to a particular preference model. In this sense, the +Pareto approach can be considered a true multi-target optimization method. + +To set up a [`ParetoObjective`](baybe.objectives.pareto.ParetoObjective), simply +specify the corresponding target objects: +```python +from baybe.targets import NumericalTarget +from baybe.objectives import ParetoObjective + +target_1 = NumericalTarget(name="t_1", mode="MIN") +target_2 = NumericalTarget(name="t_2", mode="MAX") +objective = ParetoObjective(targets=[target_1, target_2]) +``` + From 3b413107ff4070c9428cff1c5dcd92781bb37233 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Fri, 14 Feb 2025 19:15:27 +0100 Subject: [PATCH 30/47] Add surrogate broadcasting mechanism --- CHANGELOG.md | 2 ++ baybe/surrogates/__init__.py | 2 ++ baybe/surrogates/base.py | 18 +++++++++++ baybe/surrogates/broadcasting.py | 53 ++++++++++++++++++++++++++++++++ examples/Multi_Target/pareto.py | 6 +++- 5 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 baybe/surrogates/broadcasting.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ca332abba..95bf47fa0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `qPSTD` acquisition function - `ParetoObjective` class for Pareto optimization of multiple targets and corresponding `qLogNoisyExpectedHypervolumeImprovement` acquisition function +- `BroadcastingSurrogate` class and corresponding `Surrogate.broadcast` method for + making single-target surrogate models multi-target compatible ### Changed - Acquisition function indicator `is_mc` has been removed in favor of new indicators diff --git a/baybe/surrogates/__init__.py b/baybe/surrogates/__init__.py index 928274da96..99285520f3 100644 --- a/baybe/surrogates/__init__.py +++ b/baybe/surrogates/__init__.py @@ -1,6 +1,7 @@ """BayBE surrogates.""" from baybe.surrogates.bandit import BetaBernoulliMultiArmedBanditSurrogate +from baybe.surrogates.broadcasting import BroadcastingSurrogate from baybe.surrogates.custom import CustomONNXSurrogate, register_custom_architecture from baybe.surrogates.gaussian_process.core import GaussianProcessSurrogate from baybe.surrogates.linear import BayesianLinearSurrogate @@ -12,6 +13,7 @@ "register_custom_architecture", "BayesianLinearSurrogate", "BetaBernoulliMultiArmedBanditSurrogate", + "BroadcastingSurrogate", "CustomONNXSurrogate", "GaussianProcessSurrogate", "MeanPredictionSurrogate", diff --git a/baybe/surrogates/base.py b/baybe/surrogates/base.py index 6a6deee824..e4958f914c 100644 --- a/baybe/surrogates/base.py +++ b/baybe/surrogates/base.py @@ -41,6 +41,8 @@ from botorch.posteriors import GPyTorchPosterior, Posterior from torch import Tensor + from baybe.surrogates.broadcasting import BroadcastingSurrogate + _ONNX_ENCODING = "latin-1" """Constant signifying the encoding for onnx byte strings in pretrained models. @@ -135,6 +137,22 @@ def to_botorch(self) -> Model: return AdapterModel(self) + def broadcast(self) -> BroadcastingSurrogate: + """Make the surrogate handle multiple targets via broadcasting. + + If the surrogate only supports single targets, this method turns it into a + multi-target surrogate by replicating the model architecture for each observed + target. The resulting copies are trained independently, but share the same + architecture. + + If the surrogate is itself already multi-target compatible, this operation + effectively disables the model's inherent multi-target mechanism by treating + it as a single-target surrogate and applying the same broadcasting mechanism. + """ + from baybe.surrogates.broadcasting import BroadcastingSurrogate + + return BroadcastingSurrogate(self) + @staticmethod def _make_parameter_scaler_factory( parameter: Parameter, diff --git a/baybe/surrogates/broadcasting.py b/baybe/surrogates/broadcasting.py new file mode 100644 index 0000000000..c5358b046c --- /dev/null +++ b/baybe/surrogates/broadcasting.py @@ -0,0 +1,53 @@ +"""Broadcasting functionality for surrogate models.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING + +import pandas as pd +from attrs import define, field +from typing_extensions import override + +from baybe.objectives.pareto import ParetoObjective +from baybe.searchspace.core import SearchSpace +from baybe.surrogates.base import SurrogateProtocol +from baybe.surrogates.gaussian_process.core import GaussianProcessSurrogate + +if TYPE_CHECKING: + from botorch.models.model import ModelList + + +@define +class BroadcastingSurrogate(SurrogateProtocol): + """A class for broadcasting single-target surrogate logic to multiple targets.""" + + _template: SurrogateProtocol = field(alias="surrogate") + """The surrogate architecture used for broadcasting.""" + + _models: list[SurrogateProtocol] = field(init=False, factory=list) + """The independent model copies used for the individual targets.""" + + @override + def fit( + self, + searchspace: SearchSpace, + objective: ParetoObjective, + measurements: pd.DataFrame, + ) -> None: + for target in objective.targets: + model = deepcopy(self._template) + model.fit(searchspace, target.to_objective(), measurements) + self._models.append(model) + + @override + def to_botorch(self) -> ModelList: + from botorch.models import ModelList + from botorch.models.model_list_gp_regression import ModelListGP + + cls = ( + ModelListGP + if isinstance(self._template, GaussianProcessSurrogate) + else ModelList + ) + return cls(*(m.to_botorch() for m in self._models)) diff --git a/examples/Multi_Target/pareto.py b/examples/Multi_Target/pareto.py index b83d904939..d3e87a1e8c 100644 --- a/examples/Multi_Target/pareto.py +++ b/examples/Multi_Target/pareto.py @@ -20,6 +20,7 @@ from baybe.parameters import NumericalContinuousParameter from baybe.recommenders import BotorchRecommender from baybe.searchspace import SearchSpace +from baybe.surrogates import GaussianProcessSurrogate from baybe.targets import NumericalTarget from baybe.utils.dataframe import arrays_to_dataframes from baybe.utils.random import set_random_seed @@ -93,7 +94,10 @@ def lookup(arr: np.ndarray) -> np.ndarray: campaign_par = Campaign( searchspace=searchspace, objective=ParetoObjective([y0, y1]), - recommender=BotorchRecommender(sequential_continuous=True), + recommender=BotorchRecommender( + surrogate_model=GaussianProcessSurrogate().broadcast(), + sequential_continuous=True, + ), ) # We feed each campaign with the same training data and request recommendations: From 3deee1ba1a0bfcc4d1658119bc11a1d36b473a1a Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Sat, 15 Feb 2025 21:15:48 +0100 Subject: [PATCH 31/47] Validate multi-target compatibility --- baybe/surrogates/base.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/baybe/surrogates/base.py b/baybe/surrogates/base.py index e4958f914c..dcd7ddf036 100644 --- a/baybe/surrogates/base.py +++ b/baybe/surrogates/base.py @@ -106,6 +106,10 @@ class Surrogate(ABC, SurrogateProtocol, SerialMixin): """Class variable encoding whether or not the surrogate supports transfer learning.""" + supports_multi_target: ClassVar[bool] = False + """Class variable encoding whether or not the surrogate is multi-target + compatible.""" + _searchspace: SearchSpace | None = field(init=False, default=None, eq=False) """The search space on which the surrogate operates. Available after fitting.""" @@ -320,6 +324,16 @@ def fit( """ # TODO: consider adding a validation step for `measurements` + # Validate multi-target compatibility + if not self.supports_multi_target and (n_targets := len(objective.targets)) > 1: + raise ValueError( + f"You attempted to train a single-target surrogate in a " + f"{n_targets}-target context. Either use a proper multi-target " + f"surrogate or consider explicitly replicating the current " + f"surrogate model using its " + f"'.{self.broadcast.__name__}' method." + ) + # When the context is unchanged, no retraining is necessary if ( searchspace == self._searchspace From 0252ef0a4d5b7d60e2de1922c7176c59d819954c Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Sat, 15 Feb 2025 21:18:20 +0100 Subject: [PATCH 32/47] Rename broadcasting.py to composite.py --- baybe/surrogates/__init__.py | 2 +- baybe/surrogates/base.py | 4 ++-- baybe/surrogates/{broadcasting.py => composite.py} | 0 3 files changed, 3 insertions(+), 3 deletions(-) rename baybe/surrogates/{broadcasting.py => composite.py} (100%) diff --git a/baybe/surrogates/__init__.py b/baybe/surrogates/__init__.py index 99285520f3..8d43a5ae6d 100644 --- a/baybe/surrogates/__init__.py +++ b/baybe/surrogates/__init__.py @@ -1,7 +1,7 @@ """BayBE surrogates.""" from baybe.surrogates.bandit import BetaBernoulliMultiArmedBanditSurrogate -from baybe.surrogates.broadcasting import BroadcastingSurrogate +from baybe.surrogates.composite import BroadcastingSurrogate from baybe.surrogates.custom import CustomONNXSurrogate, register_custom_architecture from baybe.surrogates.gaussian_process.core import GaussianProcessSurrogate from baybe.surrogates.linear import BayesianLinearSurrogate diff --git a/baybe/surrogates/base.py b/baybe/surrogates/base.py index dcd7ddf036..cf3c47c773 100644 --- a/baybe/surrogates/base.py +++ b/baybe/surrogates/base.py @@ -41,7 +41,7 @@ from botorch.posteriors import GPyTorchPosterior, Posterior from torch import Tensor - from baybe.surrogates.broadcasting import BroadcastingSurrogate + from baybe.surrogates.composite import BroadcastingSurrogate _ONNX_ENCODING = "latin-1" """Constant signifying the encoding for onnx byte strings in pretrained models. @@ -153,7 +153,7 @@ def broadcast(self) -> BroadcastingSurrogate: effectively disables the model's inherent multi-target mechanism by treating it as a single-target surrogate and applying the same broadcasting mechanism. """ - from baybe.surrogates.broadcasting import BroadcastingSurrogate + from baybe.surrogates.composite import BroadcastingSurrogate return BroadcastingSurrogate(self) diff --git a/baybe/surrogates/broadcasting.py b/baybe/surrogates/composite.py similarity index 100% rename from baybe/surrogates/broadcasting.py rename to baybe/surrogates/composite.py From 26c7e3653fb6eda03aaa7d859a9366fe89b930e6 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 17 Feb 2025 07:50:55 +0100 Subject: [PATCH 33/47] Add CompositeSurrogate class --- baybe/surrogates/__init__.py | 3 ++- baybe/surrogates/composite.py | 41 ++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/baybe/surrogates/__init__.py b/baybe/surrogates/__init__.py index 8d43a5ae6d..7c56419d7d 100644 --- a/baybe/surrogates/__init__.py +++ b/baybe/surrogates/__init__.py @@ -1,7 +1,7 @@ """BayBE surrogates.""" from baybe.surrogates.bandit import BetaBernoulliMultiArmedBanditSurrogate -from baybe.surrogates.composite import BroadcastingSurrogate +from baybe.surrogates.composite import BroadcastingSurrogate, CompositeSurrogate from baybe.surrogates.custom import CustomONNXSurrogate, register_custom_architecture from baybe.surrogates.gaussian_process.core import GaussianProcessSurrogate from baybe.surrogates.linear import BayesianLinearSurrogate @@ -14,6 +14,7 @@ "BayesianLinearSurrogate", "BetaBernoulliMultiArmedBanditSurrogate", "BroadcastingSurrogate", + "CompositeSurrogate", "CustomONNXSurrogate", "GaussianProcessSurrogate", "MeanPredictionSurrogate", diff --git a/baybe/surrogates/composite.py b/baybe/surrogates/composite.py index c5358b046c..7ddfc205a8 100644 --- a/baybe/surrogates/composite.py +++ b/baybe/surrogates/composite.py @@ -1,4 +1,4 @@ -"""Broadcasting functionality for surrogate models.""" +"""Composite surrogate models.""" from __future__ import annotations @@ -51,3 +51,42 @@ def to_botorch(self) -> ModelList: else ModelList ) return cls(*(m.to_botorch() for m in self._models)) + + +@define +class CompositeSurrogate(SurrogateProtocol): + """A class for composing multi-target surrogates from single-target surrogates.""" + + surrogates: dict[str, SurrogateProtocol] = field() + """A dictionary mapping target names to single-target surrogates.""" + + _target_names: tuple[str, ...] = field(init=False) + """The names of the targets modeled by the surrogate outputs.""" + + @override + def fit( + self, + searchspace: SearchSpace, + objective: ParetoObjective, + measurements: pd.DataFrame, + ) -> None: + for target in objective.targets: + self.surrogates[target.name].fit( + searchspace, target.to_objective(), measurements + ) + self._target_names = tuple(t.name for t in objective.targets) + + @override + def to_botorch(self) -> ModelList: + from botorch.models import ModelList + from botorch.models.model_list_gp_regression import ModelListGP + + cls = ( + ModelListGP + if all( + isinstance(s, GaussianProcessSurrogate) + for s in self.surrogates.values() + ) + else ModelList + ) + return cls(*(self.surrogates[t].to_botorch() for t in self._target_names)) From d22bdaca82aae2439f3baa4de7d94c45724a3e81 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 17 Feb 2025 08:50:33 +0100 Subject: [PATCH 34/47] Add surrogate composition test --- tests/test_surrogate.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_surrogate.py b/tests/test_surrogate.py index c0a44a968b..f5ffb00c05 100644 --- a/tests/test_surrogate.py +++ b/tests/test_surrogate.py @@ -2,7 +2,16 @@ from unittest.mock import patch +import pandas as pd +import pytest + +from baybe.objectives.pareto import ParetoObjective +from baybe.parameters.numerical import NumericalContinuousParameter +from baybe.recommenders.pure.bayesian.botorch import BotorchRecommender +from baybe.recommenders.pure.nonpredictive.sampling import RandomRecommender +from baybe.surrogates.composite import BroadcastingSurrogate, CompositeSurrogate from baybe.surrogates.gaussian_process.core import GaussianProcessSurrogate +from baybe.targets.numerical import NumericalTarget @patch.object(GaussianProcessSurrogate, "_fit") @@ -20,3 +29,25 @@ def test_caching(patched, searchspace, objective, fake_measurements): # Second call surrogate.fit(searchspace, objective, fake_measurements) patched.assert_not_called() + + +@pytest.mark.parametrize( + "meta_surrogate_cls", [BroadcastingSurrogate, CompositeSurrogate] +) +def test_composite_surrogates(meta_surrogate_cls): + """Composition yields a valid surrogate.""" + t1 = NumericalTarget("t1", "MAX") + t2 = NumericalTarget("t1", "MIN") + searchspace = NumericalContinuousParameter("p", [0, 1]).to_searchspace() + objective = ParetoObjective([t1, t2]) + measurements = pd.DataFrame({"p": [0], "t1": [0], "t2": [0]}) + + if issubclass(meta_surrogate_cls, BroadcastingSurrogate): + surrogate = BroadcastingSurrogate(GaussianProcessSurrogate()) + else: + surrogate = CompositeSurrogate( + {"t1": GaussianProcessSurrogate(), "t2": RandomRecommender()} + ) + BotorchRecommender(surrogate_model=surrogate).recommend( + 2, searchspace, objective, measurements + ) From 3e867d270faa1f01f763b9d1f87451b5ea4f597e Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 17 Feb 2025 08:58:51 +0100 Subject: [PATCH 35/47] Add TODO note --- baybe/surrogates/gaussian_process/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/baybe/surrogates/gaussian_process/core.py b/baybe/surrogates/gaussian_process/core.py index 2534f5914c..e547b4b7cc 100644 --- a/baybe/surrogates/gaussian_process/core.py +++ b/baybe/surrogates/gaussian_process/core.py @@ -78,6 +78,8 @@ def get_numerical_indices(self, n_inputs: int) -> tuple[int, ...]: class GaussianProcessSurrogate(Surrogate): """A Gaussian process surrogate model.""" + # TODO: Enable multi-target support via batching + # Note [Scaling Workaround] # ------------------------- # For GPs, we deactivate the base class scaling and instead let the botorch From 966cfd79c229edc6d072ca763ebfb9e60ed68fd8 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 17 Feb 2025 09:29:02 +0100 Subject: [PATCH 36/47] Add qLogNoisyExpectedHypervolumeImprovement to strategy --- tests/hypothesis_strategies/acquisition.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/hypothesis_strategies/acquisition.py b/tests/hypothesis_strategies/acquisition.py index 775311600e..eeb4a936ba 100644 --- a/tests/hypothesis_strategies/acquisition.py +++ b/tests/hypothesis_strategies/acquisition.py @@ -12,6 +12,7 @@ qExpectedImprovement, qKnowledgeGradient, qLogExpectedImprovement, + qLogNoisyExpectedHypervolumeImprovement, qLogNoisyExpectedImprovement, qNegIntegratedPosteriorVariance, qNoisyExpectedImprovement, @@ -65,4 +66,5 @@ def _qNIPV_strategy(draw: st.DrawFn): st.builds(qNoisyExpectedImprovement), st.builds(qLogNoisyExpectedImprovement), _qNIPV_strategy(), + st.builds(qLogNoisyExpectedHypervolumeImprovement), ) From 43872a23af431e063e86a9326693c5d60e592652 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 17 Feb 2025 09:35:10 +0100 Subject: [PATCH 37/47] Add missing strategy arguments --- tests/hypothesis_strategies/acquisition.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/hypothesis_strategies/acquisition.py b/tests/hypothesis_strategies/acquisition.py index eeb4a936ba..94487e06c6 100644 --- a/tests/hypothesis_strategies/acquisition.py +++ b/tests/hypothesis_strategies/acquisition.py @@ -46,6 +46,14 @@ def _qNIPV_strategy(draw: st.DrawFn): ) +@st.composite +def _ref_points(draw: st.DrawFn): + """Draw reference points for hypervolume improvement acquisition functions.""" + if draw(st.booleans()): + return draw(st.lists(finite_floats(), min_size=1)) + return draw(finite_floats()) + + # These acqfs are ordered roughly according to increasing complexity acquisition_functions = st.one_of( st.builds(ExpectedImprovement), @@ -63,8 +71,12 @@ def _qNIPV_strategy(draw: st.DrawFn): st.builds( qKnowledgeGradient, num_fantasies=st.integers(min_value=1, max_value=512) ), - st.builds(qNoisyExpectedImprovement), - st.builds(qLogNoisyExpectedImprovement), + st.builds(qNoisyExpectedImprovement, prune_baseline=st.booleans()), + st.builds(qLogNoisyExpectedImprovement, prune_baseline=st.booleans()), _qNIPV_strategy(), - st.builds(qLogNoisyExpectedHypervolumeImprovement), + st.builds( + qLogNoisyExpectedHypervolumeImprovement, + prune_baseline=st.booleans(), + ref_point=_ref_points(), + ), ) From c157d01081b2571e6feee01f496601bdd0683f52 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 17 Feb 2025 10:15:47 +0100 Subject: [PATCH 38/47] Add pareto_objectives strategy and serialization test --- tests/hypothesis_strategies/objectives.py | 18 ++++++++++++++---- .../test_objective_serialization.py | 2 ++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/hypothesis_strategies/objectives.py b/tests/hypothesis_strategies/objectives.py index 94dc034a14..f155b63335 100644 --- a/tests/hypothesis_strategies/objectives.py +++ b/tests/hypothesis_strategies/objectives.py @@ -4,12 +4,19 @@ from baybe.objectives.desirability import DesirabilityObjective from baybe.objectives.enum import Scalarizer +from baybe.objectives.pareto import ParetoObjective from baybe.objectives.single import SingleTargetObjective from ..hypothesis_strategies.basic import finite_floats from ..hypothesis_strategies.targets import numerical_targets from ..hypothesis_strategies.utils import intervals as st_intervals +_intervals = st_intervals(exclude_fully_unbounded=True, exclude_half_bounded=True) + +_targets = st.lists( + numerical_targets(_intervals), min_size=2, unique_by=lambda t: t.name +) + def single_target_objectives(): """Generate :class:`baybe.objectives.single.SingleTargetObjective`.""" @@ -19,10 +26,7 @@ def single_target_objectives(): @st.composite def desirability_objectives(draw: st.DrawFn): """Generate :class:`baybe.objectives.desirability.DesirabilityObjective`.""" - intervals = st_intervals(exclude_fully_unbounded=True, exclude_half_bounded=True) - targets = draw( - st.lists(numerical_targets(intervals), min_size=2, unique_by=lambda t: t.name) - ) + targets = draw(_targets) weights = draw( st.lists( finite_floats(min_value=0.0, exclude_min=True), @@ -32,3 +36,9 @@ def desirability_objectives(draw: st.DrawFn): ) scalarizer = draw(st.sampled_from(Scalarizer)) return DesirabilityObjective(targets, weights, scalarizer) + + +@st.composite +def pareto_objectives(draw: st.DrawFn): + """Generate :class:`baybe.objectives.pareto.ParetoObjective`.""" + return ParetoObjective(draw(_targets)) diff --git a/tests/serialization/test_objective_serialization.py b/tests/serialization/test_objective_serialization.py index 77e649e53f..9e14a4d5e7 100644 --- a/tests/serialization/test_objective_serialization.py +++ b/tests/serialization/test_objective_serialization.py @@ -8,6 +8,7 @@ from baybe.objectives.base import Objective from tests.hypothesis_strategies.objectives import ( desirability_objectives, + pareto_objectives, single_target_objectives, ) @@ -17,6 +18,7 @@ [ param(single_target_objectives(), id="SingleTargetObjective"), param(desirability_objectives(), id="DesirabilityObjective"), + param(pareto_objectives(), id="ParetoObjective"), ], ) @given(data=st.data()) From 3383fd91f77769ea34180e3538509751c948ed8e Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 17 Feb 2025 10:59:05 +0100 Subject: [PATCH 39/47] Fix default acquisition function mechanism --- baybe/recommenders/pure/bayesian/base.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/baybe/recommenders/pure/bayesian/base.py b/baybe/recommenders/pure/bayesian/base.py index 13f08fa146..d0095fd8c8 100644 --- a/baybe/recommenders/pure/bayesian/base.py +++ b/baybe/recommenders/pure/bayesian/base.py @@ -66,10 +66,13 @@ def surrogate_model(self) -> SurrogateProtocol: ) return self._surrogate_model - @staticmethod - def _default_acquisition_function(objective: Objective) -> AcquisitionFunction: + def _default_acquisition_function( + self, objective: Objective + ) -> AcquisitionFunction: """Select the appropriate default acquisition function for the given context.""" - return qLogEI() if len(objective.targets) == 1 else qLogNEHVI() + if self.acquisition_function is None: + return qLogEI() if len(objective.targets) == 1 else qLogNEHVI() + return self.acquisition_function def get_surrogate( self, From 5918719adfdf9bdfe6642d6565755fc9e0315a24 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 17 Feb 2025 11:08:25 +0100 Subject: [PATCH 40/47] Throw exception when using single-target acqf in multi-target context --- baybe/acquisition/base.py | 5 +++++ baybe/recommenders/pure/bayesian/base.py | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/baybe/acquisition/base.py b/baybe/acquisition/base.py index 19ec44567b..9bb58dfb8c 100644 --- a/baybe/acquisition/base.py +++ b/baybe/acquisition/base.py @@ -58,6 +58,11 @@ def supports_pending_experiments(cls) -> bool: """ return cls.supports_batching + @classproperty + def supports_multi_target(cls) -> bool: + """Flag indicating whether multiple targets are supported.""" + return "Hypervolume" in cls.__name__ + @classproperty def _non_botorch_attrs(cls) -> tuple[str, ...]: """Names of attributes that are not passed to the BoTorch constructor.""" diff --git a/baybe/recommenders/pure/bayesian/base.py b/baybe/recommenders/pure/bayesian/base.py index d0095fd8c8..bdae7fc585 100644 --- a/baybe/recommenders/pure/bayesian/base.py +++ b/baybe/recommenders/pure/bayesian/base.py @@ -94,8 +94,18 @@ def _setup_botorch_acqf( pending_experiments: pd.DataFrame | None = None, ) -> None: """Create the acquisition function for the current training data.""" # noqa: E501 - surrogate = self.get_surrogate(searchspace, objective, measurements) self._acqf = self._default_acquisition_function(objective) + + if ( + not self._acqf.supports_multi_target + and (n_targets := len(objective.targets)) > 1 + ): + raise ValueError( + f"You attempted to use a single-target acquisition function in a " + f"{n_targets}-target context." + ) + + surrogate = self.get_surrogate(searchspace, objective, measurements) self._botorch_acqf = self._acqf.to_botorch( surrogate, searchspace, From 727ac317cac03bfb2ca4b79b937e5848b943bc99 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 17 Feb 2025 11:11:32 +0100 Subject: [PATCH 41/47] Use specific incompatibility errors instead of generic ValueError --- baybe/exceptions.py | 4 ++++ baybe/recommenders/pure/bayesian/base.py | 8 ++++++-- baybe/surrogates/base.py | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/baybe/exceptions.py b/baybe/exceptions.py index a6a60777ff..8e01c9ec65 100644 --- a/baybe/exceptions.py +++ b/baybe/exceptions.py @@ -25,6 +25,10 @@ class IncompatibleSearchSpaceError(IncompatibilityError): """ +class IncompatibleSurrogateError(IncompatibilityError): + """An incompatible surrogate was selected.""" + + class IncompatibleAcquisitionFunctionError(IncompatibilityError): """An incompatible acquisition function was selected.""" diff --git a/baybe/recommenders/pure/bayesian/base.py b/baybe/recommenders/pure/bayesian/base.py index bdae7fc585..0c84c81f41 100644 --- a/baybe/recommenders/pure/bayesian/base.py +++ b/baybe/recommenders/pure/bayesian/base.py @@ -12,7 +12,11 @@ from baybe.acquisition import qLogEI, qLogNEHVI from baybe.acquisition.base import AcquisitionFunction from baybe.acquisition.utils import convert_acqf -from baybe.exceptions import DeprecationError, InvalidSurrogateModelError +from baybe.exceptions import ( + DeprecationError, + IncompatibleAcquisitionFunctionError, + InvalidSurrogateModelError, +) from baybe.objectives.base import Objective from baybe.recommenders.pure.base import PureRecommender from baybe.searchspace import SearchSpace @@ -100,7 +104,7 @@ def _setup_botorch_acqf( not self._acqf.supports_multi_target and (n_targets := len(objective.targets)) > 1 ): - raise ValueError( + raise IncompatibleAcquisitionFunctionError( f"You attempted to use a single-target acquisition function in a " f"{n_targets}-target context." ) diff --git a/baybe/surrogates/base.py b/baybe/surrogates/base.py index cf3c47c773..770e23f922 100644 --- a/baybe/surrogates/base.py +++ b/baybe/surrogates/base.py @@ -20,7 +20,7 @@ from joblib.hashing import hash from typing_extensions import override -from baybe.exceptions import ModelNotTrainedError +from baybe.exceptions import IncompatibleSurrogateError, ModelNotTrainedError from baybe.objectives.base import Objective from baybe.parameters.base import Parameter from baybe.searchspace import SearchSpace @@ -326,7 +326,7 @@ def fit( # Validate multi-target compatibility if not self.supports_multi_target and (n_targets := len(objective.targets)) > 1: - raise ValueError( + raise IncompatibleSurrogateError( f"You attempted to train a single-target surrogate in a " f"{n_targets}-target context. Either use a proper multi-target " f"surrogate or consider explicitly replicating the current " From d124ffd83df4df7726ebd368b720f7db1d8ede32 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 17 Feb 2025 12:48:52 +0100 Subject: [PATCH 42/47] Drop opinionated statement from user guide --- docs/userguide/objectives.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/userguide/objectives.md b/docs/userguide/objectives.md index 761fa72adb..e015e31a74 100644 --- a/docs/userguide/objectives.md +++ b/docs/userguide/objectives.md @@ -123,8 +123,7 @@ between the targets must be specified *in advance*, through explicit target weights. By contrast, the Pareto approach allows to specify this trade-off *after* the experiments have been carried out, giving the user the flexibly to adjust their preferences post-hoc – knowing that each of the obtained points is optimal -with respect to a particular preference model. In this sense, the -Pareto approach can be considered a true multi-target optimization method. +with respect to a particular preference model. To set up a [`ParetoObjective`](baybe.objectives.pareto.ParetoObjective), simply specify the corresponding target objects: From 78fee0c167fb7c614492da817c2f1f898b1b34cc Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 17 Feb 2025 13:26:01 +0100 Subject: [PATCH 43/47] Mention requirement of multi-target acquisition function in user guide --- docs/userguide/objectives.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/userguide/objectives.md b/docs/userguide/objectives.md index e015e31a74..e95e7f5d54 100644 --- a/docs/userguide/objectives.md +++ b/docs/userguide/objectives.md @@ -115,15 +115,17 @@ A target configuration is considered non-dominated if no other configuration is in *all* targets. ``` -Identifying the Pareto front requires maintaining explicit models for each of the target -involved. This differs from the [`DesirabilityObjective`](#DesirabilityObjective), -which relies on a single predictive model to describe the associated -desirability values. However, the drawback of the latter is that the exact trade-off -between the targets must be specified *in advance*, through explicit target -weights. By contrast, the Pareto approach allows to specify this trade-off -*after* the experiments have been carried out, giving the user the flexibly to adjust -their preferences post-hoc – knowing that each of the obtained points is optimal -with respect to a particular preference model. +Identifying the Pareto front requires maintaining explicit models for each of the +targets involved. Accordingly, it requires to use acquisition functions capable of +processing vector-valued input, such as +{class}`~baybe.acquisition.acqfs.qLogNoisyExpectedHypervolumeImprovement`. This differs +from the [`DesirabilityObjective`](#DesirabilityObjective), which relies on a single +predictive model to describe the associated desirability values. However, the drawback +of the latter is that the exact trade-off between the targets must be specified *in +advance*, through explicit target weights. By contrast, the Pareto approach allows to +specify this trade-off *after* the experiments have been carried out, giving the user +the flexibly to adjust their preferences post-hoc – knowing that each of the obtained +points is optimal with respect to a particular preference model. To set up a [`ParetoObjective`](baybe.objectives.pareto.ParetoObjective), simply specify the corresponding target objects: From b3b77fbc8d3a1d13cb321cdf935aab4c4c01d3e7 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Mon, 17 Feb 2025 10:17:26 +0100 Subject: [PATCH 44/47] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95bf47fa0f..a4aa77f7c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `qLogNoisyExpectedHypervolumeImprovement` acquisition function - `BroadcastingSurrogate` class and corresponding `Surrogate.broadcast` method for making single-target surrogate models multi-target compatible +- `CompositeSurrogate` class for composing multi-target surrogates from single-target + surrogates +- `supports_multi_target` attribute/property to `Surrogate`/`AcquisitionFunction` ### Changed - Acquisition function indicator `is_mc` has been removed in favor of new indicators From dc3d6015b4f43a04fd3a6a1e6d69145b50270e34 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Tue, 18 Feb 2025 10:03:06 +0100 Subject: [PATCH 45/47] Rename supports_multi_target to support_multi_output --- CHANGELOG.md | 2 +- baybe/acquisition/base.py | 4 ++-- baybe/recommenders/pure/bayesian/base.py | 2 +- baybe/surrogates/base.py | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4aa77f7c0..6d76571a2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 making single-target surrogate models multi-target compatible - `CompositeSurrogate` class for composing multi-target surrogates from single-target surrogates -- `supports_multi_target` attribute/property to `Surrogate`/`AcquisitionFunction` +- `supports_multi_output` attribute/property to `Surrogate`/`AcquisitionFunction` ### Changed - Acquisition function indicator `is_mc` has been removed in favor of new indicators diff --git a/baybe/acquisition/base.py b/baybe/acquisition/base.py index 9bb58dfb8c..5cd2c57b7d 100644 --- a/baybe/acquisition/base.py +++ b/baybe/acquisition/base.py @@ -59,8 +59,8 @@ def supports_pending_experiments(cls) -> bool: return cls.supports_batching @classproperty - def supports_multi_target(cls) -> bool: - """Flag indicating whether multiple targets are supported.""" + def supports_multi_output(cls) -> bool: + """Flag indicating whether multiple outputs are supported.""" return "Hypervolume" in cls.__name__ @classproperty diff --git a/baybe/recommenders/pure/bayesian/base.py b/baybe/recommenders/pure/bayesian/base.py index 0c84c81f41..9945f8d1fe 100644 --- a/baybe/recommenders/pure/bayesian/base.py +++ b/baybe/recommenders/pure/bayesian/base.py @@ -101,7 +101,7 @@ def _setup_botorch_acqf( self._acqf = self._default_acquisition_function(objective) if ( - not self._acqf.supports_multi_target + not self._acqf.supports_multi_output and (n_targets := len(objective.targets)) > 1 ): raise IncompatibleAcquisitionFunctionError( diff --git a/baybe/surrogates/base.py b/baybe/surrogates/base.py index 770e23f922..4e15c0c388 100644 --- a/baybe/surrogates/base.py +++ b/baybe/surrogates/base.py @@ -106,8 +106,8 @@ class Surrogate(ABC, SurrogateProtocol, SerialMixin): """Class variable encoding whether or not the surrogate supports transfer learning.""" - supports_multi_target: ClassVar[bool] = False - """Class variable encoding whether or not the surrogate is multi-target + supports_multi_output: ClassVar[bool] = False + """Class variable encoding whether or not the surrogate is multi-output compatible.""" _searchspace: SearchSpace | None = field(init=False, default=None, eq=False) @@ -325,7 +325,7 @@ def fit( # TODO: consider adding a validation step for `measurements` # Validate multi-target compatibility - if not self.supports_multi_target and (n_targets := len(objective.targets)) > 1: + if not self.supports_multi_output and (n_targets := len(objective.targets)) > 1: raise IncompatibleSurrogateError( f"You attempted to train a single-target surrogate in a " f"{n_targets}-target context. Either use a proper multi-target " From f8b6b614685a689045fca8e5545ef701e1719b43 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Tue, 18 Feb 2025 11:02:27 +0100 Subject: [PATCH 46/47] Fix Liskov --- baybe/surrogates/composite.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/baybe/surrogates/composite.py b/baybe/surrogates/composite.py index 7ddfc205a8..710bd17489 100644 --- a/baybe/surrogates/composite.py +++ b/baybe/surrogates/composite.py @@ -9,7 +9,7 @@ from attrs import define, field from typing_extensions import override -from baybe.objectives.pareto import ParetoObjective +from baybe.objectives.base import Objective from baybe.searchspace.core import SearchSpace from baybe.surrogates.base import SurrogateProtocol from baybe.surrogates.gaussian_process.core import GaussianProcessSurrogate @@ -32,7 +32,7 @@ class BroadcastingSurrogate(SurrogateProtocol): def fit( self, searchspace: SearchSpace, - objective: ParetoObjective, + objective: Objective, measurements: pd.DataFrame, ) -> None: for target in objective.targets: @@ -67,7 +67,7 @@ class CompositeSurrogate(SurrogateProtocol): def fit( self, searchspace: SearchSpace, - objective: ParetoObjective, + objective: Objective, measurements: pd.DataFrame, ) -> None: for target in objective.targets: From ec9570fb4851abefa07a9590510114bcb0a339e0 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Tue, 18 Feb 2025 11:05:08 +0100 Subject: [PATCH 47/47] Ignore typing problem in classproperty --- baybe/acquisition/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/baybe/acquisition/base.py b/baybe/acquisition/base.py index 5cd2c57b7d..5d6ae4f1c5 100644 --- a/baybe/acquisition/base.py +++ b/baybe/acquisition/base.py @@ -61,7 +61,7 @@ def supports_pending_experiments(cls) -> bool: @classproperty def supports_multi_output(cls) -> bool: """Flag indicating whether multiple outputs are supported.""" - return "Hypervolume" in cls.__name__ + return "Hypervolume" in cls.__name__ # type: ignore[attr-defined] @classproperty def _non_botorch_attrs(cls) -> tuple[str, ...]: