Skip to content

Commit ebdf363

Browse files
Carl Hvarfnermeta-codesync[bot]
authored andcommitted
Noise module (facebook#4760)
Summary: Pull Request resolved: facebook#4760 T249209321 Introduces a unified Noise abstraction for benchmark problems, replacing the split responsibility between noise_std on BenchmarkProblem and add_custom_noise on BenchmarkTestFunction. Adds two noise implementations: GaussianNoise - standard IID Gaussian noise with configurable noise_std GaussianMixtureNoise - mixture of Gaussians for heteroskedastic problems Subsequent diff migrates current benchmarking to incorporate these changes. Reviewed By: saitcakmak Differential Revision: D90596997 Privacy Context Container: L1413903 fbshipit-source-id: 31ae5e772695f54f85c940b4c4717b5c7dff8a72
1 parent f575efe commit ebdf363

File tree

2 files changed

+438
-0
lines changed

2 files changed

+438
-0
lines changed

ax/benchmark/noise.py

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
#
3+
# This source code is licensed under the MIT license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
# pyre-strict
7+
8+
"""
9+
Noise classes for benchmark problems.
10+
11+
Each `BenchmarkProblem` specifies a `Noise` instance that determines how
12+
noise is added to the ground-truth evaluations. This allows for
13+
mixing and matching of test functions (specifying the mean) with noise
14+
models.
15+
16+
The abstract base class is `Noise`; subclasses include `GaussianNoise`
17+
and `GaussianMixtureNoise`.
18+
"""
19+
20+
from abc import ABC, abstractmethod
21+
from collections.abc import Mapping, Sequence
22+
from dataclasses import dataclass, field
23+
from math import sqrt
24+
25+
import numpy as np
26+
import numpy.typing as npt
27+
import pandas as pd
28+
import torch
29+
from ax.core.base_trial import BaseTrial
30+
from torch import Tensor
31+
from torch.distributions import Categorical, MixtureSameFamily, Normal
32+
33+
34+
@dataclass(kw_only=True)
35+
class Noise(ABC):
36+
"""
37+
Abstract base class for noise in benchmark problems.
38+
39+
A `Noise` object is responsible for adding noise to the ground-truth
40+
evaluations produced by a `BenchmarkTestFunction`.
41+
42+
Subclasses must implement `_get_noise_and_sem` to specify how noise
43+
samples and standard errors are generated.
44+
"""
45+
46+
@abstractmethod
47+
def _get_noise_and_sem(
48+
self,
49+
df: pd.DataFrame,
50+
outcome_names: Sequence[str],
51+
arm_weights: Mapping[str, float] | None,
52+
) -> tuple[npt.NDArray, npt.NDArray | float]:
53+
"""
54+
Generate noise samples and standard errors for each row in the DataFrame.
55+
56+
Args:
57+
df: A DataFrame with columns including
58+
["metric_name", "arm_name", "Y_true"].
59+
outcome_names: The names of the outcomes.
60+
arm_weights: Mapping from arm name to weight, or None for
61+
single-arm trials.
62+
63+
Returns:
64+
A tuple of (noise_samples, sem) where:
65+
- noise_samples: Array of noise values to add to Y_true
66+
- sem: Array of standard errors (or a scalar like NaN)
67+
"""
68+
...
69+
70+
def add_noise(
71+
self,
72+
df: pd.DataFrame,
73+
trial: BaseTrial | None,
74+
outcome_names: Sequence[str],
75+
arm_weights: Mapping[str, float] | None,
76+
) -> pd.DataFrame:
77+
"""
78+
Add noise to the ground-truth evaluations.
79+
80+
This method is the same for all Noise subclasses. It calls
81+
`_get_noise_and_sem` to get the noise samples and standard errors,
82+
then adds them to the DataFrame.
83+
84+
Args:
85+
df: A DataFrame with columns including
86+
["metric_name", "arm_name", "Y_true"].
87+
trial: The trial being evaluated.
88+
outcome_names: The names of the outcomes.
89+
arm_weights: Mapping from arm name to weight, or None for
90+
single-arm trials. Using arm weights will increase noise
91+
levels, since each arm is assumed to receive a fraction
92+
of the total sample budget.
93+
94+
Returns:
95+
The original `df`, now with additional columns ["mean", "sem"].
96+
"""
97+
noise, sem = self._get_noise_and_sem(
98+
df=df, outcome_names=outcome_names, arm_weights=arm_weights
99+
)
100+
df["mean"] = df["Y_true"] + noise
101+
df["sem"] = sem
102+
return df
103+
104+
105+
@dataclass(kw_only=True)
106+
class GaussianNoise(Noise):
107+
"""
108+
Gaussian (normal) noise with specified standard deviation.
109+
110+
This is the most common noise model for benchmark problems, where
111+
IID random normal noise is added to each observation.
112+
113+
Args:
114+
noise_std: The standard deviation of the noise. Can be:
115+
- A float: The same noise level is used for all outcomes.
116+
- A mapping from outcome name to noise level: Different noise
117+
levels for specific outcomes.
118+
"""
119+
120+
noise_std: float | Mapping[str, float] = 0.0
121+
122+
def get_noise_stds(self, outcome_names: Sequence[str]) -> dict[str, float]:
123+
"""
124+
Get a dictionary mapping outcome names to noise standard deviations.
125+
126+
Args:
127+
outcome_names: The names of the outcomes.
128+
129+
Returns:
130+
A dictionary mapping each outcome name to its noise standard deviation.
131+
"""
132+
noise_std = self.noise_std
133+
if isinstance(noise_std, float | int):
134+
return {name: float(noise_std) for name in outcome_names}
135+
if not set(noise_std.keys()) == set(outcome_names):
136+
raise ValueError(
137+
"Noise std must have keys equal to outcome names if given as a dict."
138+
)
139+
return dict(noise_std)
140+
141+
@property
142+
def is_noiseless(self) -> bool:
143+
"""Whether this noise model adds no noise."""
144+
noise_std = self.noise_std
145+
if isinstance(noise_std, float | int):
146+
return noise_std == 0.0
147+
return all(v == 0 for v in noise_std.values())
148+
149+
def _get_noise_and_sem(
150+
self,
151+
df: pd.DataFrame,
152+
outcome_names: Sequence[str],
153+
arm_weights: Mapping[str, float] | None,
154+
) -> tuple[npt.NDArray, npt.NDArray | float]:
155+
"""
156+
Generate Gaussian noise samples and standard errors.
157+
158+
For each row in ``df``, compute the standard error based on
159+
``noise_stds[metric_name]`` adjusted by arm weights if applicable,
160+
then sample noise from a normal distribution with that standard error.
161+
162+
Args:
163+
df: A DataFrame with columns ["metric_name", "arm_name", "Y_true"].
164+
outcome_names: The names of the outcomes.
165+
arm_weights: Mapping from arm name to weight, or None.
166+
167+
Returns:
168+
A tuple of (noise_samples, sem_array).
169+
"""
170+
noise_stds = self.get_noise_stds(outcome_names)
171+
noiseless = all(v == 0 for v in noise_stds.values())
172+
173+
if noiseless:
174+
return np.zeros(len(df)), 0.0
175+
176+
noise_std_ser = df["metric_name"].map(noise_stds)
177+
if arm_weights is not None:
178+
nlzd_arm_weights_sqrt = {
179+
arm_name: sqrt(weight / sum(arm_weights.values()))
180+
for arm_name, weight in arm_weights.items()
181+
}
182+
arm_weights_ser = df["arm_name"].map(nlzd_arm_weights_sqrt)
183+
sem = noise_std_ser / arm_weights_ser
184+
else:
185+
sem = noise_std_ser
186+
187+
noise = np.random.normal(loc=0, scale=sem)
188+
return noise, sem.to_numpy()
189+
190+
191+
def _create_gaussian_mixture(
192+
mixture_weights: Tensor,
193+
mixture_means: Tensor,
194+
mixture_stds: Tensor,
195+
) -> MixtureSameFamily:
196+
"""Create a Gaussian mixture distribution using PyTorch distributions.
197+
198+
Args:
199+
mixture_weights: Weights for each Gaussian component (must sum to 1).
200+
mixture_means: Means for each Gaussian component.
201+
mixture_stds: Standard deviations for each Gaussian component.
202+
203+
Returns:
204+
A MixtureSameFamily distribution representing the Gaussian mixture.
205+
"""
206+
weight_sum = mixture_weights.sum().item()
207+
if not torch.isclose(torch.tensor(weight_sum), torch.tensor(1.0)):
208+
raise ValueError(f"mixture_weights must sum to 1, got {weight_sum}")
209+
mix = Categorical(probs=mixture_weights)
210+
comp = Normal(loc=mixture_means, scale=mixture_stds)
211+
return MixtureSameFamily(mix, comp)
212+
213+
214+
@dataclass(kw_only=True)
215+
class GaussianMixtureNoise(Noise):
216+
"""
217+
Gaussian mixture noise for benchmark problems with non-Gaussian noise.
218+
219+
This noise model samples from a mixture of Gaussians, which can
220+
represent more complex noise distributions than a single Gaussian.
221+
222+
The noise is scaled by `scale` to match the scale of the outcomes.
223+
224+
Args:
225+
weights: Weights for each Gaussian component (must sum to 1).
226+
means: Means for each Gaussian component.
227+
stds: Standard deviations for each Gaussian component.
228+
scale: Scaling factor for the noise (typically the standard
229+
deviation of the true outcomes).
230+
"""
231+
232+
weights: Tensor
233+
means: Tensor
234+
stds: Tensor
235+
scale: float = 1.0
236+
_distribution: MixtureSameFamily = field(init=False, repr=False)
237+
238+
def __post_init__(self) -> None:
239+
self._distribution = _create_gaussian_mixture(
240+
self.weights, self.means, self.stds
241+
)
242+
243+
def _get_noise_and_sem(
244+
self,
245+
df: pd.DataFrame,
246+
outcome_names: Sequence[str],
247+
arm_weights: Mapping[str, float] | None,
248+
) -> tuple[npt.NDArray, npt.NDArray | float]:
249+
"""
250+
Generate Gaussian mixture noise samples.
251+
252+
Args:
253+
df: A DataFrame with columns ["metric_name", "arm_name", "Y_true"].
254+
outcome_names: The names of the outcomes (not used).
255+
arm_weights: Mapping from arm name to weight (not used).
256+
257+
Returns:
258+
A tuple of (noise_samples, NaN) since GMM noise doesn't have
259+
a simple standard error representation.
260+
"""
261+
n_samples = len(df)
262+
noise = self._distribution.sample((n_samples,)).numpy() * self.scale
263+
return noise, float("nan")

0 commit comments

Comments
 (0)