Skip to content

Commit eaaea07

Browse files
esantorellafacebook-github-bot
authored andcommitted
Runner should only need to know about outcomes, not objectives vs. constraints (facebook#2963)
Summary: Context: In theory, a `BenchmarkRunner` should not have to know what metrics are objectives or constraints, and a test function should not have to be aware of that, either. They are just generating data. A `BenchmarkProblem` should only store knowledge of objectives and constraints on the `OptimizationConfig`, so that various `OptimizationConfigs` can be used without changing the runner and test function. For historical reasons, runners track objectives and constraints separately and add noise to them separately, because this mimics how BoTorch test functions handle this. However, we now can and should isolate the quirks of BoTorch test functions to `BoTorchTestProblem`. This diff: * Updates `ParamBasedTestFunction.evaluate_true` to return all outcomes, not just objectives, and gets rid of `ParamBasedTestFunction.evaluate_true`, which was for constraints * Removes `num_objectives` from `ParamBasedTestProblem`, leaving `ParamBasedTestProblem` with nothing but an `evaluate_true` method * Removes the argument `constraint_noise_std` from `create_problem_from_botorch` and from `ParamBasedTestProblemRunner`, in favor of just using `noise_std`. * Updates argument validation Tangentially related changes: * For simplicity, makes `get_noise_stds` always return a dict * Stops allowing `noise_std` to be `None` and defaults it to zero (it was eventually set to zero when it was None in the past) Differential Revision: D64919207
1 parent 10d2603 commit eaaea07

File tree

9 files changed

+148
-202
lines changed

9 files changed

+148
-202
lines changed

ax/benchmark/benchmark_problem.py

+3-8
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,7 @@ def create_problem_from_botorch(
301301
*,
302302
test_problem_class: type[BaseTestProblem],
303303
test_problem_kwargs: dict[str, Any],
304-
noise_std: float | list[float] | None = None,
305-
constraint_noise_std: float | list[float] | None = None,
304+
noise_std: float | list[float] = 0.0,
306305
num_trials: int,
307306
lower_is_better: bool = True,
308307
observe_noise_sd: bool = False,
@@ -321,11 +320,8 @@ def create_problem_from_botorch(
321320
to define the `search_space`, `optimization_config`, and `runner`.
322321
test_problem_kwargs: Keyword arguments used to instantiate the
323322
`test_problem_class`.
324-
noise_std: Standard deviation of synthetic noise added to objectives. If
325-
`None`, no noise is added. If a float, the same noise level is used
326-
for all objectives.
327-
constraint_noise_std: Standard deviation of synthetic noise added to
328-
constraints.
323+
noise_std: Standard deviation of synthetic noise added to outcomes. If a
324+
float, the same noise level is used for all objectives.
329325
lower_is_better: Whether this is a minimization problem. For MOO, this
330326
applies to all objectives.
331327
num_trials: Simply the `num_trials` of the `BenchmarkProblem` created.
@@ -392,7 +388,6 @@ def create_problem_from_botorch(
392388
param_names=list(search_space.parameters.keys()),
393389
),
394390
noise_std=noise_std,
395-
constraint_noise_std=constraint_noise_std,
396391
),
397392
num_trials=num_trials,
398393
observe_noise_stds=observe_noise_sd,

ax/benchmark/problems/hpo/torchvision.py

-1
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ def train_and_evaluate(
118118
@dataclass(kw_only=True)
119119
class PyTorchCNNTorchvisionParamBasedProblem(ParamBasedTestProblem):
120120
name: str # The name of the dataset to load -- MNIST or FashionMNIST
121-
num_objectives: int = 1
122121
device: torch.device = field(
123122
default_factory=lambda: torch.device(
124123
"cuda" if torch.cuda.is_available() else "cpu"

ax/benchmark/problems/synthetic/hss/jenatton.py

-2
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,6 @@ def jenatton_test_function(
5555
class Jenatton(ParamBasedTestProblem):
5656
"""Jenatton test function for hierarchical search spaces."""
5757

58-
num_objectives: int = 1
59-
6058
# pyre-fixme[14]: Inconsistent override
6159
def evaluate_true(self, params: Mapping[str, float | int | None]) -> torch.Tensor:
6260
# pyre-fixme: Incompatible parameter type [6]: In call

ax/benchmark/runners/base.py

+9-14
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def evaluate_oracle(self, parameters: Mapping[str, TParamValue]) -> ndarray:
8484
return self.get_Y_true(params=params).numpy()
8585

8686
@abstractmethod
87-
def get_noise_stds(self) -> None | float | dict[str, float]:
87+
def get_noise_stds(self) -> dict[str, float]:
8888
"""
8989
Return the standard errors for the synthetic noise to be applied to the
9090
observed values.
@@ -110,7 +110,9 @@ def run(self, trial: BaseTrial) -> dict[str, Any]:
110110
Ys, Ystds = {}, {}
111111
noise_stds = self.get_noise_stds()
112112

113-
if noise_stds is not None:
113+
noiseless = all(v == 0 for v in noise_stds.values())
114+
115+
if not noiseless:
114116
# extract arm weights to adjust noise levels accordingly
115117
if isinstance(trial, BatchTrial):
116118
# normalize arm weights (we assume that the noise level is defined)
@@ -122,22 +124,15 @@ def run(self, trial: BaseTrial) -> dict[str, Any]:
122124
else:
123125
nlzd_arm_weights = {checked_cast(Trial, trial).arm: 1.0}
124126
# generate a tensor of noise levels that we'll reuse below
125-
if isinstance(noise_stds, float):
126-
noise_stds_tsr = torch.full(
127-
(len(self.outcome_names),),
128-
noise_stds,
129-
dtype=torch.double,
130-
)
131-
else:
132-
noise_stds_tsr = torch.tensor(
133-
[noise_stds[metric_name] for metric_name in self.outcome_names],
134-
dtype=torch.double,
135-
)
127+
noise_stds_tsr = torch.tensor(
128+
[noise_stds[metric_name] for metric_name in self.outcome_names],
129+
dtype=torch.double,
130+
)
136131

137132
for arm in trial.arms:
138133
# Case where we do have a ground truth
139134
Y_true = self.get_Y_true(arm.parameters)
140-
if noise_stds is None:
135+
if noiseless:
141136
# No noise, so just return the true outcome.
142137
Ystds[arm.name] = [0.0] * len(Y_true)
143138
Ys[arm.name] = Y_true.tolist()

ax/benchmark/runners/botorch_test.py

+30-87
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313
import torch
1414
from ax.benchmark.runners.base import BenchmarkRunner
1515
from ax.core.types import TParamValue
16-
from ax.exceptions.core import UnsupportedError
17-
from botorch.test_functions.multi_objective import MultiObjectiveTestProblem
1816
from botorch.test_functions.synthetic import BaseTestProblem, ConstrainedBaseTestProblem
1917
from botorch.utils.transforms import normalize, unnormalize
2018
from torch import Tensor
@@ -28,17 +26,15 @@ class ParamBasedTestProblem(ABC):
2826
(Noise - if desired - is added by the runner.)
2927
"""
3028

31-
num_objectives: int
32-
3329
@abstractmethod
3430
def evaluate_true(self, params: Mapping[str, TParamValue]) -> Tensor:
35-
"""Evaluate noiselessly."""
36-
...
31+
"""
32+
Evaluate noiselessly.
3733
38-
def evaluate_slack_true(self, params: Mapping[str, TParamValue]) -> Tensor:
39-
raise NotImplementedError(
40-
f"{self.__class__.__name__} does not support constraints."
41-
)
34+
Returns:
35+
1d tensor of shape (num_outcomes,).
36+
"""
37+
...
4238

4339

4440
@dataclass(kw_only=True)
@@ -57,24 +53,18 @@ class BoTorchTestProblem(ParamBasedTestProblem):
5753
5 will correspond to 0.5 while evaluating the test problem.
5854
If modified bounds are not provided, the test problem will be
5955
evaluated using the raw parameter values.
60-
num_objectives: The number of objectives.
6156
"""
6257

6358
botorch_problem: BaseTestProblem
6459
modified_bounds: list[tuple[float, float]] | None = None
65-
num_objectives: int = 1
6660

6761
def __post_init__(self) -> None:
68-
if isinstance(self.botorch_problem, MultiObjectiveTestProblem):
69-
self.num_objectives = self.botorch_problem.num_objectives
70-
if self.botorch_problem.noise_std is not None:
71-
raise ValueError(
72-
"noise_std should be set on the runner, not the test problem."
73-
)
74-
if getattr(self.botorch_problem, "constraint_noise_std", None) is not None:
62+
if (
63+
self.botorch_problem.noise_std is not None
64+
or getattr(self.botorch_problem, "constraint_noise_std", None) is not None
65+
):
7566
raise ValueError(
76-
"constraint_noise_std should be set on the runner, not the test "
77-
"problem."
67+
"noise should be set on the `BenchmarkRunner`, not the test function."
7868
)
7969
self.botorch_problem = self.botorch_problem.to(dtype=torch.double)
8070

@@ -96,20 +86,11 @@ def tensorize_params(self, params: Mapping[str, int | float]) -> torch.Tensor:
9686
# pyre-fixme [14]: inconsistent override
9787
def evaluate_true(self, params: Mapping[str, float | int]) -> torch.Tensor:
9888
x = self.tensorize_params(params=params)
99-
return self.botorch_problem(x)
100-
101-
# pyre-fixme [14]: inconsistent override
102-
def evaluate_slack_true(self, params: Mapping[str, float | int]) -> torch.Tensor:
103-
if not isinstance(self.botorch_problem, ConstrainedBaseTestProblem):
104-
raise UnsupportedError(
105-
"`evaluate_slack_true` is only supported when the BoTorch "
106-
"problem is a `ConstrainedBaseTestProblem`."
107-
)
108-
# todo: could return x so as to not recompute
109-
# or could do both methods together, track indices of outcomes,
110-
# and only negate the non-constraints
111-
x = self.tensorize_params(params=params)
112-
return self.botorch_problem.evaluate_slack_true(x)
89+
objectives = self.botorch_problem(x).view(-1)
90+
if isinstance(self.botorch_problem, ConstrainedBaseTestProblem):
91+
constraints = self.botorch_problem.evaluate_slack_true(x).view(-1)
92+
return torch.cat([objectives, constraints], dim=-1)
93+
return objectives
11394

11495

11596
@dataclass(kw_only=True)
@@ -119,7 +100,7 @@ class ParamBasedTestProblemRunner(BenchmarkRunner):
119100
120101
Given a trial, the Runner will use its `test_problem` to evaluate the
121102
problem noiselessly for each arm in the trial, and then add noise as
122-
specified by the `noise_std` and `constraint_noise_std`. It will return
103+
specified by the `noise_std`. It will return
123104
metadata including the outcome names and values of metrics.
124105
125106
Args:
@@ -132,64 +113,26 @@ class ParamBasedTestProblemRunner(BenchmarkRunner):
132113
"""
133114

134115
test_problem: ParamBasedTestProblem
135-
noise_std: float | list[float] | None = None
136-
constraint_noise_std: float | list[float] | None = None
116+
noise_std: float | list[float] | dict[str, float] = 0.0
137117

138-
@property
139-
def _is_constrained(self) -> bool:
140-
return isinstance(self.test_problem, BoTorchTestProblem) and isinstance(
141-
self.test_problem.botorch_problem, ConstrainedBaseTestProblem
142-
)
143-
144-
def get_noise_stds(self) -> None | float | dict[str, float]:
118+
def get_noise_stds(self) -> dict[str, float]:
145119
noise_std = self.noise_std
146-
noise_std_dict: dict[str, float] = {}
147-
num_obj = self.test_problem.num_objectives
148-
149-
# populate any noise_stds for constraints
150-
if self._is_constrained:
151-
constraint_noise_std = self.constraint_noise_std
152-
if isinstance(constraint_noise_std, list):
153-
for i, cns in enumerate(constraint_noise_std, start=num_obj):
154-
if cns is not None:
155-
noise_std_dict[self.outcome_names[i]] = cns
156-
elif constraint_noise_std is not None:
157-
noise_std_dict[self.outcome_names[num_obj]] = constraint_noise_std
158-
159-
# if none of the constraints are subject to noise, then we may return
160-
# a single float or None for the noise level
161-
162-
if not noise_std_dict and not isinstance(noise_std, list):
163-
return noise_std # either a float or None
164-
165-
if isinstance(noise_std, list):
166-
if not len(noise_std) == num_obj:
167-
# this shouldn't be possible due to validation upon construction
168-
# of the multi-objective problem, but better safe than sorry
120+
if isinstance(noise_std, float):
121+
return {name: noise_std for name in self.outcome_names}
122+
elif isinstance(noise_std, dict):
123+
if not set(noise_std.keys()) == set(self.outcome_names):
169124
raise ValueError(
170-
"Noise std must have length equal to number of objectives."
125+
"Noise std must have keys equal to outcome names if given as "
126+
"a dict."
171127
)
172-
else:
173-
noise_std = [noise_std for _ in range(num_obj)]
174-
175-
for i, noise_std_ in enumerate(noise_std):
176-
if noise_std_ is not None:
177-
noise_std_dict[self.outcome_names[i]] = noise_std_
178-
179-
return noise_std_dict
128+
return noise_std
129+
# list of floats
130+
return dict(zip(self.outcome_names, noise_std, strict=True))
180131

181132
def get_Y_true(self, params: Mapping[str, TParamValue]) -> Tensor:
182133
"""Evaluates the test problem.
183134
184135
Returns:
185-
A `batch_shape x m`-dim tensor of ground truth (noiseless) evaluations.
136+
An `m`-dim tensor of ground truth (noiseless) evaluations.
186137
"""
187-
Y_true = self.test_problem.evaluate_true(params).view(-1)
188-
if self._is_constrained:
189-
# Convention: Concatenate objective and black box constraints. `view()`
190-
# makes the inputs 1d, so the resulting `Y_true` are also 1d.
191-
Y_true = torch.cat(
192-
[Y_true, self.test_problem.evaluate_slack_true(params).view(-1)],
193-
dim=-1,
194-
)
195-
return Y_true
138+
return torch.atleast_1d(self.test_problem.evaluate_true(params=params))

ax/benchmark/runners/surrogate.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,11 @@ def datasets(self) -> list[SupervisedDataset]:
8484
self.set_surrogate_and_datasets()
8585
return none_throws(self._datasets)
8686

87-
def get_noise_stds(self) -> None | float | dict[str, float]:
88-
return self.noise_stds
87+
def get_noise_stds(self) -> dict[str, float]:
88+
noise_std = self.noise_stds
89+
if isinstance(noise_std, float):
90+
return {name: noise_std for name in self.outcome_names}
91+
return noise_std
8992

9093
# pyre-fixme[14]: Inconsistent override
9194
def get_Y_true(self, params: Mapping[str, float | int]) -> Tensor:

0 commit comments

Comments
 (0)