55
66# pyre-strict
77
8+ import warnings
89from collections .abc import Iterable , Mapping , Sequence
910from dataclasses import dataclass , field
10- from math import sqrt
1111from typing import Any
1212
1313import numpy as np
1616from ax .benchmark .benchmark_step_runtime_function import TBenchmarkStepRuntimeFunction
1717from ax .benchmark .benchmark_test_function import BenchmarkTestFunction
1818from ax .benchmark .benchmark_trial_metadata import BenchmarkTrialMetadata
19+ from ax .benchmark .noise import GaussianNoise , Noise
1920from ax .core .base_trial import BaseTrial , TrialStatus
2021from ax .core .batch_trial import BatchTrial
2122from ax .core .runner import Runner
@@ -69,54 +70,6 @@ def _dict_of_arrays_to_df(
6970 return df
7071
7172
72- def _add_noise (
73- df : pd .DataFrame ,
74- noise_stds : Mapping [str , float ],
75- arm_weights : Mapping [str , float ] | None ,
76- ) -> pd .DataFrame :
77- """
78- For each ``Y_true`` in ``df``, with metric name ``metric_name`` and
79- arm name ``arm_name``, add noise with standard deviation
80- ``noise_stds[metric_name] / sqrt_nlzd_arm_weights[arm_name]``,
81- where ``sqrt_nlzd_arm_weights = sqrt(arm_weights[arm_name] /
82- sum(arm_weights.values())])``.
83-
84- Args:
85- df: A DataFrame with columns including
86- ["metric_name", "arm_name", "Y_true"].
87- noise_stds: A mapping from metric name to what the standard
88- deviation would be if one arm received the entire
89- sample budget.
90- arm_weights: Either ``None`` if there is only one ``Arm``, or a
91- mapping from ``Arm`` name to the arm's allocation. Using arm
92- weights will increase noise levels, since each ``Arm`` is
93- assumed to receive a fraction of the total sample budget.
94-
95- Returns:
96- The original ``df``, now with additional columns ["mean", "sem"].
97- """
98- noiseless = all (v == 0 for v in noise_stds .values ())
99- if not noiseless :
100- noise_std_ser = df ["metric_name" ].map (noise_stds )
101- if arm_weights is not None :
102- nlzd_arm_weights_sqrt = {
103- arm_name : sqrt (weight / sum (arm_weights .values ()))
104- for arm_name , weight in arm_weights .items ()
105- }
106- arm_weights_ser = df ["arm_name" ].map (nlzd_arm_weights_sqrt )
107- df ["sem" ] = noise_std_ser / arm_weights_ser
108-
109- else :
110- df ["sem" ] = noise_std_ser
111-
112- df ["mean" ] = df ["Y_true" ] + np .random .normal (loc = 0 , scale = df ["sem" ])
113-
114- else :
115- df ["sem" ] = 0.0
116- df ["mean" ] = df ["Y_true" ]
117- return df
118-
119-
12073def get_total_runtime (
12174 trial : BaseTrial ,
12275 step_runtime_function : TBenchmarkStepRuntimeFunction | None ,
@@ -139,7 +92,7 @@ class BenchmarkRunner(Runner):
13992 A Runner that produces both observed and ground-truth values.
14093
14194 Observed values equal ground-truth values plus noise, with the noise added
142- according to the standard deviations returned by `get_noise_stds()` .
95+ according to the `Noise` object provided .
14396
14497 This runner does require that every benchmark has a ground truth, which
14598 won't necessarily be true for real-world problems. Such problems fall into
@@ -161,8 +114,10 @@ class BenchmarkRunner(Runner):
161114 Args:
162115 test_function: A ``BenchmarkTestFunction`` from which to generate
163116 deterministic data before adding noise.
164- noise_std: The standard deviation of the noise added to the data. Can be
165- a dict to be per-metric.
117+ noise: A ``Noise`` object that determines how noise is added to the
118+ ground-truth evaluations. Defaults to noiseless
119+ (``GaussianNoise(noise_std=0.0)``).
120+ noise_std: Deprecated. Use ``noise`` instead.
166121 step_runtime_function: A function that takes in parameters
167122 (in ``TParameterization`` format) and returns the runtime of a step.
168123 max_concurrency: The maximum number of trials that can be running at a
@@ -175,24 +130,30 @@ class BenchmarkRunner(Runner):
175130 """
176131
177132 test_function : BenchmarkTestFunction
178- noise_std : float | Mapping [str , float ] = 0.0
133+ noise : Noise = field (default_factory = GaussianNoise )
134+ noise_std : float | Mapping [str , float ] | None = None
179135 step_runtime_function : TBenchmarkStepRuntimeFunction | None = None
180136 max_concurrency : int = 1
181137 force_use_simulated_backend : bool = False
182138 simulated_backend_runner : SimulatedBackendRunner | None = field (init = False )
183139
184140 def __post_init__ (self ) -> None :
185- # Check for conflicting noise configuration
186- has_custom_noise = self .test_function .add_custom_noise is not None
187-
188- # This works for both lists and dicts, and the user specifies anything
189- # other than 0.0 as noise_std alongside a custom noise, we error out.
190- if has_custom_noise and (self .noise_std != 0.0 ):
191- raise ValueError (
192- "Cannot specify both `add_custom_noise` on the test function and "
193- "a `noise_std`. Either use `add_custom_noise` for custom "
194- "noise behavior or `noise_std` for default noise behavior."
141+ # Handle backward compatibility for noise_std parameter
142+ if self .noise_std is not None :
143+ warnings .warn (
144+ "noise_std is deprecated. Use noise=GaussianNoise(noise_std=...) "
145+ "instead." ,
146+ DeprecationWarning ,
147+ stacklevel = 2 ,
195148 )
149+ # Check if noise was also explicitly set (not default)
150+ if not isinstance (self .noise , GaussianNoise ) or self .noise .noise_std != 0.0 :
151+ raise ValueError (
152+ "Cannot specify both 'noise_std' and a non-default 'noise'. "
153+ "Use only 'noise=GaussianNoise(noise_std=...)' instead."
154+ )
155+ # Convert noise_std to GaussianNoise (use 0 if None)
156+ self .noise = GaussianNoise (noise_std = self .noise_std or 0 )
196157
197158 use_simulated_backend = (
198159 (self .max_concurrency > 1 )
@@ -238,16 +199,6 @@ def get_Y_true(self, params: Mapping[str, TParamValue]) -> npt.NDArray:
238199 return result [:, None ]
239200 return result
240201
241- def get_noise_stds (self ) -> dict [str , float ]:
242- noise_std = self .noise_std
243- if isinstance (noise_std , float | int ):
244- return {name : float (noise_std ) for name in self .outcome_names }
245- if not set (noise_std .keys ()) == set (self .outcome_names ):
246- raise ValueError (
247- "Noise std must have keys equal to outcome names if given as a dict."
248- )
249- return dict (noise_std )
250-
251202 def run (self , trial : BaseTrial ) -> dict [str , BenchmarkTrialMetadata ]:
252203 """Run the trial by evaluating its parameterization(s).
253204
@@ -286,15 +237,13 @@ def run(self, trial: BaseTrial) -> dict[str, BenchmarkTrialMetadata]:
286237 if isinstance (trial , BatchTrial )
287238 else None
288239 )
289- # Check for custom noise function, otherwise use default noise behavior
290- if self .test_function .add_custom_noise is not None :
291- df = self .test_function .add_custom_noise (
292- df , trial , self .get_noise_stds (), arm_weights
293- )
294- else :
295- df = _add_noise (
296- df = df , noise_stds = self .get_noise_stds (), arm_weights = arm_weights
297- )
240+ # Use the Noise object to add noise to the ground-truth evaluations
241+ df = self .noise .add_noise (
242+ df = df ,
243+ trial = trial ,
244+ outcome_names = self .outcome_names ,
245+ arm_weights = arm_weights ,
246+ )
298247 df ["trial_index" ] = trial .index
299248 df .drop (columns = ["Y_true" ], inplace = True )
300249 df ["metric_signature" ] = df ["metric_name" ]
0 commit comments