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

fix chebyshev scalariztaion #1616

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 35 additions & 17 deletions botorch/utils/multi_objective/scalarization.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from typing import Callable, Optional

import torch
from botorch.exceptions.errors import BotorchTensorDimensionError
from botorch.exceptions.errors import BotorchTensorDimensionError, UnsupportedError
from botorch.utils.transforms import normalize
from torch import Tensor

Expand All @@ -29,16 +29,18 @@ def get_chebyshev_scalarization(
) -> Callable[[Tensor, Optional[Tensor]], Tensor]:
r"""Construct an augmented Chebyshev scalarization.

Augmented Chebyshev scalarization:
objective(y) = min(w * y) + alpha * sum(w * y)
The augmented Chebyshev scalarization is given by
g(y) = max_i(w_i * y_i) + alpha * sum_i(w_i * y_i)

Outcomes are first normalized to [0,1] for maximization (or [-1,0] for minimization)
and then an augmented Chebyshev scalarization is applied.
where the goal is to minimize g(y) in the setting where all objectives y_i are
to be minimized. Since the default in BoTorch is to maximize all objectives,
this method constructs a Chebyshev scalarization where the inputs are first
multiplied by -1, so that all objectives are to be minimized. Then, it computes
g(y) (which should be minimized), and returns -g(y), which should be maximized.

Note: this assumes maximization of the augmented Chebyshev scalarization.
Minimizing/Maximizing an objective is supported by passing a negative/positive
weight for that objective. To make all w * y's have positive sign
such that they are comparable when computing min(w * y), outcomes of minimization
Minimizing an objective is supported by passing a negative
weight for that objective. To make all w * y's have the same sign
such that they are comparable when computing max(w * y), outcomes of minimization
objectives are shifted from [0,1] to [-1,0].

See [Knowles2005]_ for details.
Expand All @@ -50,7 +52,8 @@ def get_chebyshev_scalarization(
weights: A `m`-dim tensor of weights.
Positive for maximization and negative for minimization.
Y: A `n x m`-dim tensor of observed outcomes, which are used for
scaling the outcomes to [0,1] or [-1,0].
scaling the outcomes to [0,1] or [-1,0]. If `n=0`, then outcomes
are left unnormalized.
alpha: Parameter governing the influence of the weighted sum term. The
default value comes from [Knowles2005]_.

Expand All @@ -61,6 +64,9 @@ def get_chebyshev_scalarization(
>>> weights = torch.tensor([0.75, -0.25])
>>> transform = get_aug_chebyshev_scalarization(weights, Y)
"""
# the chebyshev_obj assumes all objectives should be minimized, so
# multiply Y by -1
Y = -Y
Copy link
Contributor

Choose a reason for hiding this comment

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

Add a comment here? As well as below in the obj definition for clarity?

if weights.shape != Y.shape[-1:]:
raise BotorchTensorDimensionError(
"weights must be an `m`-dim tensor where Y is `... x m`."
Expand All @@ -71,11 +77,24 @@ def get_chebyshev_scalarization(

def chebyshev_obj(Y: Tensor, X: Optional[Tensor] = None) -> Tensor:
product = weights * Y
return product.min(dim=-1).values + alpha * product.sum(dim=-1)
return product.max(dim=-1).values + alpha * product.sum(dim=-1)

# A boolean mask indicating if minimizing an objective
minimize = weights < 0
if Y.shape[-2] == 0:
if minimize.any():
raise UnsupportedError(
"negative weights (for minimization) are only supported if "
"Y is provided."
)
# If there are no observations, we do not need to normalize the objectives
return chebyshev_obj

def obj(Y: Tensor, X: Optional[Tensor] = None) -> Tensor:
# multiply the scalarization by -1, so that the scalarization should
# be maximized
return -chebyshev_obj(Y=-Y)

return obj
if Y.shape[-2] == 1:
# If there is only one observation, set the bounds to be
# [min(Y_m), min(Y_m) + 1] for each objective m. This ensures we do not
Expand All @@ -85,15 +104,14 @@ def chebyshev_obj(Y: Tensor, X: Optional[Tensor] = None) -> Tensor:
# Set the bounds to be [min(Y_m), max(Y_m)], for each objective m
Y_bounds = torch.stack([Y.min(dim=-2).values, Y.max(dim=-2).values])

# A boolean mask indicating if minimizing an objective
minimize = weights < 0

def obj(Y: Tensor, X: Optional[Tensor] = None) -> Tensor:
# scale to [0,1]
Y_normalized = normalize(Y, bounds=Y_bounds)
Y_normalized = normalize(-Y, bounds=Y_bounds)
# If minimizing an objective, convert Y_normalized values to [-1,0],
# such that min(w*y) makes sense, we want all w*y's to be positive
Y_normalized[..., minimize] = Y_normalized[..., minimize] - 1
return chebyshev_obj(Y=Y_normalized)
# multiply the scalarization by -1, so that the scalarization should
# be maximized
return -chebyshev_obj(Y=Y_normalized)

return obj
73 changes: 46 additions & 27 deletions test/utils/multi_objective/test_scalarization.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from __future__ import annotations

import torch
from botorch.exceptions.errors import BotorchTensorDimensionError
from botorch.exceptions.errors import BotorchTensorDimensionError, UnsupportedError
from botorch.utils.multi_objective.scalarization import get_chebyshev_scalarization
from botorch.utils.testing import BotorchTestCase
from botorch.utils.transforms import normalize
Expand All @@ -17,20 +17,22 @@ class TestGetChebyshevScalarization(BotorchTestCase):
def test_get_chebyshev_scalarization(self):
tkwargs = {"device": self.device}
Y_train = torch.rand(4, 2, **tkwargs)
Y_bounds = torch.stack(
neg_Y_train = -Y_train
neg_Y_bounds = torch.stack(
[
Y_train.min(dim=-2, keepdim=True).values,
Y_train.max(dim=-2, keepdim=True).values,
neg_Y_train.min(dim=-2, keepdim=True).values,
neg_Y_train.max(dim=-2, keepdim=True).values,
],
dim=0,
)
for dtype in (torch.float, torch.double):
for batch_shape in (torch.Size([]), torch.Size([3])):
tkwargs["dtype"] = dtype
Y_test = torch.rand(batch_shape + torch.Size([5, 2]), **tkwargs)
neg_Y_test = -Y_test
Y_train = Y_train.to(**tkwargs)
Y_bounds = Y_bounds.to(**tkwargs)
normalized_Y_test = normalize(Y_test, Y_bounds)
neg_Y_bounds = neg_Y_bounds.to(**tkwargs)
normalized_neg_Y_test = normalize(neg_Y_test, neg_Y_bounds)
# test wrong shape
with self.assertRaises(BotorchTensorDimensionError):
get_chebyshev_scalarization(
Expand All @@ -45,28 +47,38 @@ def test_get_chebyshev_scalarization(self):
weights=weights, Y=Y_train
)
Y_transformed = objective_transform(Y_test)
expected_Y_transformed = normalized_Y_test.min(
dim=-1
).values + 0.05 * normalized_Y_test.sum(dim=-1)
expected_Y_transformed = -(
normalized_neg_Y_test.max(dim=-1).values
+ 0.05 * normalized_neg_Y_test.sum(dim=-1)
)
self.assertTrue(torch.equal(Y_transformed, expected_Y_transformed))
# check that using negative objectives and negative weights
# yields an equivalent scalarized outcome
objective_transform2 = get_chebyshev_scalarization(
weights=-weights, Y=-Y_train
)
Y_transformed2 = objective_transform2(-Y_test)
self.assertAllClose(Y_transformed, Y_transformed2)
# test different alpha
objective_transform = get_chebyshev_scalarization(
weights=weights, Y=Y_train, alpha=1.0
)
Y_transformed = objective_transform(Y_test)
expected_Y_transformed = normalized_Y_test.min(
dim=-1
).values + normalized_Y_test.sum(dim=-1)
expected_Y_transformed = -(
normalized_neg_Y_test.max(dim=-1).values
+ normalized_neg_Y_test.sum(dim=-1)
)
self.assertTrue(torch.equal(Y_transformed, expected_Y_transformed))
# Test different weights
weights = torch.tensor([0.3, 0.7], **tkwargs)
objective_transform = get_chebyshev_scalarization(
weights=weights, Y=Y_train
)
Y_transformed = objective_transform(Y_test)
expected_Y_transformed = (weights * normalized_Y_test).min(
dim=-1
).values + 0.05 * (weights * normalized_Y_test).sum(dim=-1)
expected_Y_transformed = -(
(weights * normalized_neg_Y_test).max(dim=-1).values
+ 0.05 * (weights * normalized_neg_Y_test).sum(dim=-1)
)
self.assertTrue(torch.equal(Y_transformed, expected_Y_transformed))
# test that when minimizing an objective (i.e. with a negative weight),
# normalized Y values are shifted from [0,1] to [-1,0]
Expand All @@ -75,29 +87,36 @@ def test_get_chebyshev_scalarization(self):
weights=weights, Y=Y_train
)
Y_transformed = objective_transform(Y_test)
normalized_Y_test[..., -1] = normalized_Y_test[..., -1] - 1
expected_Y_transformed = (weights * normalized_Y_test).min(
dim=-1
).values + 0.05 * (weights * normalized_Y_test).sum(dim=-1)
normalized_neg_Y_test[..., -1] = normalized_neg_Y_test[..., -1] - 1
expected_Y_transformed = -(
(weights * normalized_neg_Y_test).max(dim=-1).values
+ 0.05 * (weights * normalized_neg_Y_test).sum(dim=-1)
)
self.assertTrue(torch.equal(Y_transformed, expected_Y_transformed))
# test that with no observations there is no normalization
weights = torch.tensor([0.3, 0.7], **tkwargs)
objective_transform = get_chebyshev_scalarization(
weights=weights, Y=Y_train[:0]
)
Y_transformed = objective_transform(Y_test)
expected_Y_transformed = (weights * Y_test).min(
dim=-1
).values + 0.05 * (weights * Y_test).sum(dim=-1)
expected_Y_transformed = -(
(weights * neg_Y_test).max(dim=-1).values
+ 0.05 * (weights * neg_Y_test).sum(dim=-1)
)
self.assertTrue(torch.equal(Y_transformed, expected_Y_transformed))
# test that with one observation, we normalize by subtracting Y_train
# test that error is raised with negative weights and empty Y
with self.assertRaises(UnsupportedError):
get_chebyshev_scalarization(weights=-weights, Y=Y_train[:0])
# test that with one observation, we normalize by subtracting
# neg_Y_train
single_Y_train = Y_train[:1]
objective_transform = get_chebyshev_scalarization(
weights=weights, Y=single_Y_train
)
Y_transformed = objective_transform(Y_test)
normalized_Y_test = Y_test - single_Y_train
expected_Y_transformed = (weights * normalized_Y_test).min(
dim=-1
).values + 0.05 * (weights * normalized_Y_test).sum(dim=-1)
normalized_neg_Y_test = neg_Y_test + single_Y_train
expected_Y_transformed = -(
(weights * normalized_neg_Y_test).max(dim=-1).values
+ 0.05 * (weights * normalized_neg_Y_test).sum(dim=-1)
)
self.assertAllClose(Y_transformed, expected_Y_transformed)