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