Skip to content

Commit f3c643c

Browse files
Carl Hvarfnerfacebook-github-bot
authored andcommitted
Migration to Noise module (facebook#4761)
Summary: Updates `benchmark.py` to use the new Noise module architecture: passes `problem.noise` instead of `problem.noise_std` to `BenchmarkRunner`, and removes the obsolete `add_custom_noise` replacement since noise is now handled entirely by the `Noise` object on the runner. Differential Revision: D90597013
1 parent 2c50680 commit f3c643c

File tree

11 files changed

+94
-298
lines changed

11 files changed

+94
-298
lines changed

ax/benchmark/benchmark.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def get_benchmark_runner(
147147

148148
return BenchmarkRunner(
149149
test_function=problem.test_function,
150-
noise_std=problem.noise_std,
150+
noise=problem.noise,
151151
step_runtime_function=problem.step_runtime_function,
152152
max_concurrency=max_concurrency,
153153
force_use_simulated_backend=force_use_simulated_backend,
@@ -189,9 +189,9 @@ def get_oracle_experiment_from_params(
189189
optimization_config=problem.optimization_config,
190190
)
191191

192-
# Ensure noiseless evaluation by replacing any custom noise function with None
193-
noiseless_test_function = replace(problem.test_function, add_custom_noise=None)
194-
runner = BenchmarkRunner(test_function=noiseless_test_function, noise_std=0.0)
192+
# The test function produces ground-truth values; noise is handled by
193+
# BenchmarkRunner's Noise object (default is noiseless GaussianNoise).
194+
runner = BenchmarkRunner(test_function=problem.test_function)
195195

196196
# Silence INFO logs from ax.core.experiment that state "Attached custom
197197
# parameterizations"

ax/benchmark/benchmark_problem.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ax.benchmark.benchmark_metric import BenchmarkMapMetric, BenchmarkMetric
1212
from ax.benchmark.benchmark_step_runtime_function import TBenchmarkStepRuntimeFunction
1313
from ax.benchmark.benchmark_test_function import BenchmarkTestFunction
14+
from ax.benchmark.noise import GaussianNoise, Noise
1415
from ax.core.auxiliary import AuxiliaryExperiment, AuxiliaryExperimentPurpose
1516
from ax.core.metric import Metric
1617
from ax.core.objective import MultiObjective, Objective, ScalarizedObjective
@@ -43,12 +44,9 @@ class BenchmarkProblem(Base):
4344
as one trial.
4445
test_function: A `BenchmarkTestFunction`, which will generate noiseless
4546
data. This will be used by a `BenchmarkRunner`.
46-
noise_std: Describes how noise is added to the output of the
47-
`test_function`. If a float, IID random normal noise with that
48-
standard deviation is added. A list of floats, or a dict whose keys
49-
match `test_functions.outcome_names`, sets different noise
50-
standard deviations for the different outcomes produced by the
51-
`test_function`. This will be used by a `BenchmarkRunner`.
47+
noise: A `Noise` object that determines how noise is added to the
48+
ground-truth evaluations produced by the `test_function`. Defaults
49+
to noiseless (`GaussianNoise(noise_std=0.0)`).
5250
optimal_value: The best ground-truth objective value, used for scoring
5351
optimization results on a scale from 0 to 100, where achieving the
5452
`optimal_value` receives a score of 100. The `optimal_value` should
@@ -93,7 +91,7 @@ class BenchmarkProblem(Base):
9391
optimization_config: OptimizationConfig
9492
num_trials: int
9593
test_function: BenchmarkTestFunction
96-
noise_std: float | Sequence[float] | Mapping[str, float] = 0.0
94+
noise: Noise = field(default_factory=GaussianNoise)
9795
optimal_value: float
9896
baseline_value: float
9997
worst_feasible_value: float | None = None

ax/benchmark/benchmark_runner.py

Lines changed: 13 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
from collections.abc import Iterable, Mapping, Sequence
99
from dataclasses import dataclass, field
10-
from math import sqrt
1110
from typing import Any
1211

1312
import numpy as np
@@ -16,6 +15,7 @@
1615
from ax.benchmark.benchmark_step_runtime_function import TBenchmarkStepRuntimeFunction
1716
from ax.benchmark.benchmark_test_function import BenchmarkTestFunction
1817
from ax.benchmark.benchmark_trial_metadata import BenchmarkTrialMetadata
18+
from ax.benchmark.noise import GaussianNoise, Noise
1919
from ax.core.base_trial import BaseTrial, TrialStatus
2020
from ax.core.batch_trial import BatchTrial
2121
from ax.core.runner import Runner
@@ -24,7 +24,6 @@
2424
from ax.runners.simulated_backend import SimulatedBackendRunner
2525
from ax.utils.common.serialization import TClassDecoderRegistry, TDecoderRegistry
2626
from ax.utils.testing.backend_simulator import BackendSimulator, BackendSimulatorOptions
27-
from pyre_extensions import assert_is_instance
2827

2928

3029
def _dict_of_arrays_to_df(
@@ -70,54 +69,6 @@ def _dict_of_arrays_to_df(
7069
return df
7170

7271

73-
def _add_noise(
74-
df: pd.DataFrame,
75-
noise_stds: Mapping[str, float],
76-
arm_weights: Mapping[str, float] | None,
77-
) -> pd.DataFrame:
78-
"""
79-
For each ``Y_true`` in ``df``, with metric name ``metric_name`` and
80-
arm name ``arm_name``, add noise with standard deviation
81-
``noise_stds[metric_name] / sqrt_nlzd_arm_weights[arm_name]``,
82-
where ``sqrt_nlzd_arm_weights = sqrt(arm_weights[arm_name] /
83-
sum(arm_weights.values())])``.
84-
85-
Args:
86-
df: A DataFrame with columns including
87-
["metric_name", "arm_name", "Y_true"].
88-
noise_stds: A mapping from metric name to what the standard
89-
deviation would be if one arm received the entire
90-
sample budget.
91-
arm_weights: Either ``None`` if there is only one ``Arm``, or a
92-
mapping from ``Arm`` name to the arm's allocation. Using arm
93-
weights will increase noise levels, since each ``Arm`` is
94-
assumed to receive a fraction of the total sample budget.
95-
96-
Returns:
97-
The original ``df``, now with additional columns ["mean", "sem"].
98-
"""
99-
noiseless = all(v == 0 for v in noise_stds.values())
100-
if not noiseless:
101-
noise_std_ser = df["metric_name"].map(noise_stds)
102-
if arm_weights is not None:
103-
nlzd_arm_weights_sqrt = {
104-
arm_name: sqrt(weight / sum(arm_weights.values()))
105-
for arm_name, weight in arm_weights.items()
106-
}
107-
arm_weights_ser = df["arm_name"].map(nlzd_arm_weights_sqrt)
108-
df["sem"] = noise_std_ser / arm_weights_ser
109-
110-
else:
111-
df["sem"] = noise_std_ser
112-
113-
df["mean"] = df["Y_true"] + np.random.normal(loc=0, scale=df["sem"])
114-
115-
else:
116-
df["sem"] = 0.0
117-
df["mean"] = df["Y_true"]
118-
return df
119-
120-
12172
def get_total_runtime(
12273
trial: BaseTrial,
12374
step_runtime_function: TBenchmarkStepRuntimeFunction | None,
@@ -140,7 +91,7 @@ class BenchmarkRunner(Runner):
14091
A Runner that produces both observed and ground-truth values.
14192
14293
Observed values equal ground-truth values plus noise, with the noise added
143-
according to the standard deviations returned by `get_noise_stds()`.
94+
according to the `Noise` object provided.
14495
14596
This runner does require that every benchmark has a ground truth, which
14697
won't necessarily be true for real-world problems. Such problems fall into
@@ -162,8 +113,9 @@ class BenchmarkRunner(Runner):
162113
Args:
163114
test_function: A ``BenchmarkTestFunction`` from which to generate
164115
deterministic data before adding noise.
165-
noise_std: The standard deviation of the noise added to the data. Can be
166-
a list or dict to be per-metric.
116+
noise: A ``Noise`` object that determines how noise is added to the
117+
ground-truth evaluations. Defaults to noiseless
118+
(``GaussianNoise(noise_std=0.0)``).
167119
step_runtime_function: A function that takes in parameters
168120
(in ``TParameterization`` format) and returns the runtime of a step.
169121
max_concurrency: The maximum number of trials that can be running at a
@@ -176,25 +128,13 @@ class BenchmarkRunner(Runner):
176128
"""
177129

178130
test_function: BenchmarkTestFunction
179-
noise_std: float | Sequence[float] | Mapping[str, float] = 0.0
131+
noise: Noise = field(default_factory=GaussianNoise)
180132
step_runtime_function: TBenchmarkStepRuntimeFunction | None = None
181133
max_concurrency: int = 1
182134
force_use_simulated_backend: bool = False
183135
simulated_backend_runner: SimulatedBackendRunner | None = field(init=False)
184136

185137
def __post_init__(self) -> None:
186-
# Check for conflicting noise configuration
187-
has_custom_noise = self.test_function.add_custom_noise is not None
188-
189-
# This works for both lists and dicts, and the user specifies anything
190-
# other than 0.0 as noise_std alongside a custom noise, we error out.
191-
if has_custom_noise and (self.noise_std != 0.0):
192-
raise ValueError(
193-
"Cannot specify both `add_custom_noise` on the test function and "
194-
"a `noise_std`. Either use `add_custom_noise` for custom "
195-
"noise behavior or `noise_std` for default noise behavior."
196-
)
197-
198138
use_simulated_backend = (
199139
(self.max_concurrency > 1)
200140
or (self.step_runtime_function is not None)
@@ -239,22 +179,6 @@ def get_Y_true(self, params: Mapping[str, TParamValue]) -> npt.NDArray:
239179
return result[:, None]
240180
return result
241181

242-
def get_noise_stds(self) -> dict[str, float]:
243-
noise_std = self.noise_std
244-
if isinstance(noise_std, float | int):
245-
return {name: float(noise_std) for name in self.outcome_names}
246-
elif isinstance(noise_std, dict):
247-
if not set(noise_std.keys()) == set(self.outcome_names):
248-
raise ValueError(
249-
"Noise std must have keys equal to outcome names if given as "
250-
"a dict."
251-
)
252-
return noise_std
253-
# list of floats
254-
return dict(
255-
zip(self.outcome_names, assert_is_instance(noise_std, list), strict=True)
256-
)
257-
258182
def run(self, trial: BaseTrial) -> dict[str, BenchmarkTrialMetadata]:
259183
"""Run the trial by evaluating its parameterization(s).
260184
@@ -293,15 +217,13 @@ def run(self, trial: BaseTrial) -> dict[str, BenchmarkTrialMetadata]:
293217
if isinstance(trial, BatchTrial)
294218
else None
295219
)
296-
# Check for custom noise function, otherwise use default noise behavior
297-
if self.test_function.add_custom_noise is not None:
298-
df = self.test_function.add_custom_noise(
299-
df, trial, self.get_noise_stds(), arm_weights
300-
)
301-
else:
302-
df = _add_noise(
303-
df=df, noise_stds=self.get_noise_stds(), arm_weights=arm_weights
304-
)
220+
# Use the Noise object to add noise to the ground-truth evaluations
221+
df = self.noise.add_noise(
222+
df=df,
223+
trial=trial,
224+
outcome_names=self.outcome_names,
225+
arm_weights=arm_weights,
226+
)
305227
df["trial_index"] = trial.index
306228
df.drop(columns=["Y_true"], inplace=True)
307229
df["metric_signature"] = df["metric_name"]

ax/benchmark/benchmark_test_function.py

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,53 +6,30 @@
66
# pyre-strict
77

88
from abc import ABC, abstractmethod
9-
from collections.abc import Callable, Mapping, Sequence
9+
from collections.abc import Mapping, Sequence
1010
from dataclasses import dataclass
1111

12-
import pandas as pd
13-
from ax.core.base_trial import BaseTrial
1412
from ax.core.types import TParamValue
1513
from torch import Tensor
1614

17-
# Type alias for the custom noise function.
18-
# The callable takes all the arguments that are exposed in the benchmark runner:
19-
# - df: The lookup_data().df DataFrame. Mandatory
20-
# - trial: The trial being evaluated
21-
# - noise_stds: Mapping from metric name to noise std
22-
# - arm_weights: Mapping from arm name to weight, or None for single-arm trials
23-
# And returns a DataFrame with added "mean" and "sem" columns.
24-
TAddCustomNoise = Callable[
25-
[
26-
pd.DataFrame,
27-
BaseTrial | None,
28-
Mapping[str, float] | None,
29-
Mapping[str, float] | None,
30-
],
31-
pd.DataFrame,
32-
]
33-
3415

3516
@dataclass(kw_only=True)
3617
class BenchmarkTestFunction(ABC):
3718
"""
3819
The basic Ax class for generating deterministic data to benchmark against.
3920
40-
(Noise - if desired - is added by the runner.)
21+
(Noise - if desired - is added by the runner using a `Noise` object.)
4122
4223
Args:
4324
outcome_names: Names of the outcomes.
4425
n_steps: Number of data points produced per metric and per evaluation. 1
4526
if data is not time-series. If data is time-series, this will
4627
eventually become the number of values on a `MapMetric` for
4728
evaluations that run to completion.
48-
add_custom_noise: Optional callable to add custom noise to evaluation
49-
results. If provided, it will be called instead of the default noise
50-
behavior, overriding the noise_std argument.
5129
"""
5230

5331
outcome_names: Sequence[str]
5432
n_steps: int = 1
55-
add_custom_noise: TAddCustomNoise | None = None
5633

5734
@abstractmethod
5835
def evaluate_true(self, params: Mapping[str, TParamValue]) -> Tensor:

ax/benchmark/problems/synthetic/bandit.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import numpy as np
1111
from ax.benchmark.benchmark_problem import BenchmarkProblem, get_soo_opt_config
1212
from ax.benchmark.benchmark_test_functions.synthetic import IdentityTestFunction
13+
from ax.benchmark.noise import GaussianNoise
1314
from ax.core.parameter import ChoiceParameter, ParameterType
1415
from ax.core.search_space import SearchSpace
1516

@@ -71,6 +72,6 @@ def get_bandit_problem(num_choices: int = 30, num_trials: int = 3) -> BenchmarkP
7172
baseline_value=baseline_value,
7273
test_function=test_function,
7374
report_inference_value_as_trace=True,
74-
noise_std=1.0,
75+
noise=GaussianNoise(noise_std=1.0),
7576
status_quo_params={"x0": num_choices // 2},
7677
)

ax/benchmark/problems/synthetic/from_botorch.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121
from ax.benchmark.benchmark_step_runtime_function import TBenchmarkStepRuntimeFunction
2222
from ax.benchmark.benchmark_test_functions.botorch_test import BoTorchTestFunction
23+
from ax.benchmark.noise import GaussianNoise
2324
from ax.core.auxiliary import AuxiliaryExperiment, AuxiliaryExperimentPurpose
2425
from ax.core.parameter import ChoiceParameter, ParameterType, RangeParameter
2526
from ax.core.search_space import SearchSpace
@@ -127,8 +128,9 @@ def create_problem_from_botorch(
127128
`test_problem_class`. This should *not* include `noise_std` or
128129
`negate`, since these are handled through Ax benchmarking (as the
129130
`noise_std` and `lower_is_better` arguments to `BenchmarkProblem`).
130-
noise_std: Standard deviation of synthetic noise added to outcomes. If a
131-
float, the same noise level is used for all objectives.
131+
noise_std: Standard deviation of synthetic Gaussian noise added to outcomes.
132+
If a float, the same noise level is used for all objectives.
133+
If a list, different noise levels are used for each objective.
132134
lower_is_better: Whether this is a minimization problem. For MOO, this
133135
applies to all objectives.
134136
num_trials: Simply the `num_trials` of the `BenchmarkProblem` created.
@@ -271,7 +273,7 @@ def create_problem_from_botorch(
271273
search_space=search_space,
272274
optimization_config=optimization_config,
273275
test_function=test_function,
274-
noise_std=noise_std,
276+
noise=GaussianNoise(noise_std=noise_std),
275277
num_trials=num_trials,
276278
optimal_value=assert_is_instance(optimal_value, float),
277279
baseline_value=baseline_value,

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import torch
1212
from ax.benchmark.benchmark_problem import BenchmarkProblem, get_soo_opt_config
1313
from ax.benchmark.benchmark_test_function import BenchmarkTestFunction
14+
from ax.benchmark.noise import GaussianNoise
1415
from ax.core.parameter import ChoiceParameter, ParameterType, RangeParameter
1516
from ax.core.search_space import SearchSpace
1617
from pyre_extensions import none_throws
@@ -116,7 +117,7 @@ def get_jenatton_benchmark_problem(
116117
search_space=get_jenatton_search_space(),
117118
optimization_config=optimization_config,
118119
test_function=Jenatton(outcome_names=[name]),
119-
noise_std=noise_std,
120+
noise=GaussianNoise(noise_std=noise_std),
120121
num_trials=num_trials,
121122
optimal_value=JENATTON_OPTIMAL_VALUE,
122123
baseline_value=JENATTON_BASELINE_VALUE,

ax/benchmark/tests/problems/synthetic/hss/test_jenatton.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from random import random
99

1010
from ax.benchmark.benchmark_metric import BenchmarkMetric
11+
from ax.benchmark.noise import GaussianNoise
1112
from ax.benchmark.problems.synthetic.hss.jenatton import (
1213
get_jenatton_benchmark_problem,
1314
jenatton_test_function,
@@ -100,7 +101,9 @@ def test_create_problem(self) -> None:
100101
self.assertEqual(metric.signature, "Jenatton")
101102
self.assertTrue(objective.minimize)
102103
self.assertTrue(metric.lower_is_better)
103-
self.assertEqual(problem.noise_std, 0.0)
104+
self.assertEqual(
105+
assert_is_instance(problem.noise, GaussianNoise).noise_std, 0.0
106+
)
104107
self.assertFalse(assert_is_instance(metric, BenchmarkMetric).observe_noise_sd)
105108

106109
problem = get_jenatton_benchmark_problem(
@@ -109,5 +112,7 @@ def test_create_problem(self) -> None:
109112
objective = problem.optimization_config.objective
110113
metric = objective.metric
111114
self.assertTrue(metric.lower_is_better)
112-
self.assertEqual(problem.noise_std, 0.1)
115+
self.assertEqual(
116+
assert_is_instance(problem.noise, GaussianNoise).noise_std, 0.1
117+
)
113118
self.assertTrue(assert_is_instance(metric, BenchmarkMetric).observe_noise_sd)

ax/benchmark/tests/problems/synthetic/test_from_botorch.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ax.benchmark.benchmark_metric import BenchmarkMapMetric, BenchmarkMetric
1212
from ax.benchmark.benchmark_problem import get_continuous_search_space
1313
from ax.benchmark.benchmark_test_functions.botorch_test import BoTorchTestFunction
14+
from ax.benchmark.noise import GaussianNoise
1415
from ax.benchmark.problems.synthetic.from_botorch import (
1516
_get_name,
1617
create_problem_from_botorch,
@@ -145,7 +146,9 @@ def _test_constrained_from_botorch(
145146
botorch_problem = assert_is_instance(
146147
test_problem.botorch_problem, ConstrainedBaseTestProblem
147148
)
148-
self.assertEqual(ax_problem.noise_std, noise_std)
149+
self.assertEqual(
150+
assert_is_instance(ax_problem.noise, GaussianNoise).noise_std, noise_std
151+
)
149152
opt_config = ax_problem.optimization_config
150153
outcome_constraints = opt_config.outcome_constraints
151154
self.assertEqual(

0 commit comments

Comments
 (0)