Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Pareto optimization #475

Open
wants to merge 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
bd6f025
Improve deprecation warning message
AdrianSosic Dec 2, 2024
cdfb622
Draft ParetoObjective class
AdrianSosic Dec 2, 2024
6c74060
Extract function for transforming target columns
AdrianSosic Dec 2, 2024
abced3a
Add qLogNEHVI acqusition function
AdrianSosic Dec 5, 2024
d72f076
Make botorch multiobjective acqusition functions autodetectable
AdrianSosic Dec 5, 2024
2e6fb02
Add temporary restriction allowing only MAX targets
AdrianSosic Dec 5, 2024
842aee6
Draft example
AdrianSosic Dec 5, 2024
555aeea
Enable minimization targets
AdrianSosic Jan 24, 2025
9613050
Add highlighted feature to README
AdrianSosic Jan 24, 2025
0df7ce4
Update CHANGELOG.md
AdrianSosic Jan 24, 2025
a011533
Compute default reference point from data
AdrianSosic Feb 3, 2025
a65fa51
Flip signs of custom reference points in MIN mode
AdrianSosic Feb 3, 2025
7b93127
Interpolate target paretor frontier along transformed points
AdrianSosic Feb 3, 2025
3adfb75
Drop unnecessary label arguments
AdrianSosic Feb 3, 2025
926a880
Drop square root from target functions
AdrianSosic Feb 3, 2025
40c6d54
Mention ParetoObjective in README
AdrianSosic Feb 3, 2025
0f5d823
Fix enum comparison operator
AdrianSosic Feb 12, 2025
02b56e7
Drop duplicate override decorator
AdrianSosic Feb 12, 2025
c4fbe8b
Fix random seed utility import
AdrianSosic Feb 12, 2025
3aa72dd
Explicitly convert targets to objectives
AdrianSosic Feb 12, 2025
b81af42
Dynamically select default acquisition function
AdrianSosic Feb 13, 2025
63efab3
Deactivate comparison for non-persistent attributes
AdrianSosic Feb 13, 2025
49e9556
Fix variable reference in example
AdrianSosic Feb 13, 2025
88240ba
Turn assert statement into proper exception
AdrianSosic Feb 13, 2025
6ccdca4
Add prune_baseline attribute
AdrianSosic Feb 13, 2025
8362904
Add full docstring to compute_ref_point
AdrianSosic Feb 13, 2025
9281087
Refactor ref_point computation logic
AdrianSosic Feb 13, 2025
3f7a7c6
Let doc generation append regular image when available
AdrianSosic Feb 13, 2025
711935f
Add ParetoObjective user guide section
AdrianSosic Feb 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Scienfitz marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Collaborator

Choose a reason for hiding this comment

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

detached comment2: no particular tests? the pareto objective is not tested. No hypothesis for it. No integrative tests like iterations (unless being done automatically but I dont think we have tests that iterate over objective types.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions README.md
Scienfitz marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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
Scienfitz marked this conversation as resolved.
Show resolved Hide resolved
- ⚖️ 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
Expand Down Expand Up @@ -78,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.
Expand Down
6 changes: 6 additions & 0 deletions baybe/acquisition/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
qExpectedImprovement,
qKnowledgeGradient,
qLogExpectedImprovement,
qLogNoisyExpectedHypervolumeImprovement,
qLogNoisyExpectedImprovement,
qNegIntegratedPosteriorVariance,
qNoisyExpectedImprovement,
Expand Down Expand Up @@ -37,6 +38,7 @@
UCB = UpperConfidenceBound
qUCB = qUpperConfidenceBound
qTS = qThompsonSampling
qLogNEHVI = qLogNoisyExpectedHypervolumeImprovement

__all__ = [
######################### Acquisition functions
Expand Down Expand Up @@ -64,6 +66,8 @@
"qUpperConfidenceBound",
# Thompson Sampling
"qThompsonSampling",
# Hypervolume Improvement
"qLogNoisyExpectedHypervolumeImprovement",
Copy link
Collaborator

Choose a reason for hiding this comment

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

why not include qExpectedHypervolumeImprovement too?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

there are in fact 4 choices: w/o log and w/o noisy. The reason why I haven't included the non-noisy versions yet is because I haven't really gotten into the partition mechanics required for those. Do you already have some insights to share here?

Copy link
Collaborator

Choose a reason for hiding this comment

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

can you explain why this matters? Is the implementation here requiring anything different for eg just swapping one of the other functions in?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yes, it requires passing an explicit partition object. Probably not a big deal, though, just haven't had the time yet to fully understand the underlying mechanism. I guess this is analog to the 1D case where for the regular EI you pass best_f but for the noisy version you don't. In that sense, the partition would act like the multidimensional generalization of best_f. Whoever of us gets there first can add the logic 👍🏼

Copy link
Collaborator

Choose a reason for hiding this comment

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

ok didnt realize that
i dont understand yet why the other methods require a partitioning but there are exact and approximate utilities that essentially only depend on ref_point and Y so in principle there should be no obstacle to compute a property that provides partitioning

Copy link
Collaborator

Choose a reason for hiding this comment

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

it further seems that the interface differenes might be due to legacy things or so, you will find for the noisy variant the alpha parameter
image
which has the same role as the alpha parameter for the partitioning utility. So it appears there the partitioning is just done internally, which imo would justify to just hardcode partitioning to be e.g. FastNondominatedPartitioning

######################### Abbreviations
# Knowledge Gradient
"qKG",
Expand All @@ -89,4 +93,6 @@
"qUCB",
# Thompson Sampling
"qTS",
# Hypervolume Improvement
"qLogNEHVI",
]
81 changes: 80 additions & 1 deletion baybe/acquisition/acqfs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import math
from typing import ClassVar

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
Expand All @@ -13,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,
Expand Down Expand Up @@ -320,5 +322,82 @@ def supports_batching(cls) -> bool:
return False


########################################################################################
### Hypervolume Improvement
@define(frozen=True)
class qLogNoisyExpectedHypervolumeImprovement(AcquisitionFunction):
Copy link
Collaborator

Choose a reason for hiding this comment

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

we have no mechanism to distinguish that this is a multi-output acqf, do we need this:?
I think this acqf could also work in 1D, is that the case?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No, there's no such mechanism at the moment. I'm not even sure if it exists in botorch / what would happen if you create an incompatible setting there. will need to check. But it definitely makes sense to somehow be able for us to validate the input and throw a correpsonding user exception. How do we go about it? Class binary flag?

Copy link
Collaborator

Choose a reason for hiding this comment

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

well first we need to know whether the HVI based acqfs also support m=1 ?If so they are available for all existing iteration tests and wed just have to create maybe a new one for multiple targets

in any case, a new property supports_multi_output or similar wouldnt hurt? so far it will only be acqfs with Hypervolume in their name

"""Logarithmic Monte Carlo based noisy expected hypervolume improvement."""

abbreviation: ClassVar[str] = "qLogNEHVI"

ref_point: float | tuple[float, ...] | None = field(
Copy link
Collaborator

Choose a reason for hiding this comment

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

do we want to include the prune_baseline keyword? Sounds useful, or could always be set to true depending on our preference

any merit to include some of the other keywords that might be useful? thinking of eta, alpha or fat

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, why not, let's agree on a subset. I'd say let's include prune_baseline with True as default. But I would not include anything that we don't yet fully understand ourselves / stuff that does not primarily affect the optimization. So if you ask me, I'd leave it with that. Opinions?

Copy link
Collaborator

@Scienfitz Scienfitz Feb 13, 2025

Choose a reason for hiding this comment

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

Looking at our implementation of X_baseline
image
we definitley need the pruning to be true as we dont do any pre-selection, perhaps theres no need to make it configurable tbh.

This made me look up what is done for the noisy non HV variante qNEI because we have that included and also set baseline to jsut all train data. Strangely, there the default for prune_baseline is True while the HI variant here has it set to False. So I would ensure its True by hardcoding it base.py

Copy link
Collaborator

Choose a reason for hiding this comment

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

For the other parameters, I think the only one I would possibly provide access to is alpha. If the other HV variants are also included they need an alpha passed to partitioning.

To simplify thing we could also not make alpha configurable but set the value according to a heuristic, it seems it should be 0.0 for m<=5, then we could add a linear increase until m=20 or so.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

But then I think it's more elegant to just add it to our qLogNEHVI wrapper with default value True just like we did for the scalar version. That way, we have a consistent useful default while still being configurable.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Well, botorch already has the following built-in logic (I guess this what you are referring to)? So if we want to use a smart non-static default, I'd rather go with just calling their internal logic instead of coding on ourselves?

image

Copy link
Collaborator

Choose a reason for hiding this comment

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

absolutely lets use this
so alpha becomes a property and not an attribute right?

default=None, converter=optional_c(convert_to_float)
)
"""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.
"""

AdrianSosic marked this conversation as resolved.
Show resolved Hide resolved
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
) -> 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."
)
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)) * maximize


# Collect leftover original slotted classes processed by `attrs.define`
gc.collect()
45 changes: 43 additions & 2 deletions baybe/acquisition/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -17,6 +18,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 (
Expand Down Expand Up @@ -76,9 +78,13 @@ 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
from baybe.acquisition.acqfs import (
qLogNoisyExpectedHypervolumeImprovement,
qThompsonSampling,
)

# Retrieve botorch acquisition function class and match attributes
acqf_cls = _get_botorch_acqf_class(type(self))
Expand Down Expand Up @@ -151,6 +157,39 @@ def to_botorch(
additional_params["best_f"] = (
bo_surrogate.posterior(train_x).mean.max().item()
)
case ParetoObjective():
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)
for t in objective.targets
):
raise NotImplementedError(
"Pareto optimization currently supports "
"maximization/minimization targets only."
)
maximize = [t.mode is TargetMode.MAX for t in objective.targets] # type: ignore[attr-defined]
multiplier = [1.0 if m else -1.0 for m in maximize]
additional_params["objective"] = WeightedMCMultiOutputObjective(
Copy link
Collaborator

Choose a reason for hiding this comment

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

would it make any sense to support weights too? Or would it just warp the pareto front and not change anything?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is an interesting question 🤔 So my thoughts are: A pareto approach is inherently "neutral", i.e. there is no notion of one target being more/less important than another. This conflicts with the idea of attaching weights to objectives from the ground up. So I'd say: no. And "weighted" objective used in the code is just an instrument to achieve the sign flip.

Nevertheless, I also wonder what would actually happen if we attached some weights... Thinking graphically, I think it would basically correspond to an axis-specific stretch of the dimensions of the target-space plot. And if that's the case, the hypervolume computation would be a bit "skewed". But in the limit when you increase the batch size, you'd still cover the same front, I guess. What are your thoughts?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I have some new insights, will share later

Copy link
Collaborator

Choose a reason for hiding this comment

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

Do you know whether the weights are applied after the single-target acqf values are calculated?

Because in that case:
Lets look at two points described by an EI value for each target (here called $t$)

$$x = (t_1, t_2)$$ $$\hat{x} = (\hat{t}_1, \hat{t}_2)$$

The hypervolume then is

$$\mathrm{HV}(x) = t_1 * t_2$$

If we assume the HVI for the first point is larger than for the second then this is true:

$$\frac{\mathrm{HV}(x)}{\mathrm{HV}(\hat{x})} > 1$$

In any other coordinate system where the $t$ values are scaled by weights $(w_1, w_2)$ we have:

$$\frac{\mathrm{HV}^{\mathrm{transformed}}(x)}{\mathrm{HV}^\mathrm{transformed}(\hat{x})} = \frac{w_1 * w_2 * t_1 *t_2}{w_1 * w_2 * \hat{t}_1 * \hat{t}_2} = \frac{t_1 *t_2}{\hat{t}_1 * \hat{t}_2} = \frac{\mathrm{HV}(x)}{\mathrm{HV}(\hat{x})} > 1$$

This is also true in case of < or =. It would mean relative order of two arb points can never be changed by any weights, making them obsolete.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Let's discuss this face-to-face, I think will be easier

torch.tensor(multiplier)
)
train_y = measurements[[t.name for t in objective.targets]].to_numpy()
AdrianSosic marked this conversation as resolved.
Show resolved Hide resolved
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 {}
ref_point = (
self.compute_ref_point(train_y, maximize, **kwargs) * multiplier
)
params_dict["ref_point"] = ref_point

case _:
raise ValueError(f"Unsupported objective type: {objective}")

Expand All @@ -172,7 +211,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
Expand Down
2 changes: 2 additions & 0 deletions baybe/objectives/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
13 changes: 4 additions & 9 deletions baybe/objectives/desirability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions baybe/objectives/pareto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""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 transform_target_columns


@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
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

return transform_target_columns(
df, self.targets, allow_missing=allow_missing, allow_extra=allow_extra
)
2 changes: 1 addition & 1 deletion baybe/objectives/single.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading