Skip to content

Commit 9d151bf

Browse files
saitcakmakfacebook-github-bot
authored andcommitted
Implement BilogY.transform_experiment_data (facebook#3887)
Summary: Pull Request resolved: facebook#3887 As titled. Supports transforming `ExperimentData` with `BilogY` transform. Also removes unnecessary `DataRequiredError`, since the transform does not actually utilize the provided data in the constructor. Background: As part of the larger refactor, we will be using `ExperimentData` in place of `list[Observation]` within the `Adapter`. - The transforms will be initialized using `ExperimentData`. The `observations` input to the constructors may be deprecated once the use cases are updated. - The training data for `Adapter` will be represented with `ExperimentData` and will be transformed using `transform_experiment_data`. - For misc input / output to various `Adapter` and other methods, the `Observation / ObservationFeatures / ObservationData` objects will remain. To support these, we will retain the existing transform methods that service these objects. - Since `ExperimentData` is not planned to be used as an output of user facing methods, we do not need to untransform it. We are not planning to implement`untransform_experiment_data`. Reviewed By: esantorella Differential Revision: D76081492 fbshipit-source-id: e4c59f64ffc9a9e34701be9db54ad2b9e81569ef
1 parent fd3adb7 commit 9d151bf

File tree

2 files changed

+93
-52
lines changed

2 files changed

+93
-52
lines changed

ax/adapter/transforms/bilog_y.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
from ax.adapter.transforms.log_y import match_ci_width
1818
from ax.core.observation import Observation, ObservationData
1919
from ax.core.search_space import SearchSpace
20-
from ax.exceptions.core import DataRequiredError
2120
from ax.generators.types import TConfig
21+
from scipy.stats import norm
2222

2323
if TYPE_CHECKING:
2424
# import as module to make sphinx-autodoc-typehints happy
@@ -68,8 +68,6 @@ def __init__(
6868
adapter=adapter,
6969
config=config,
7070
)
71-
if observations is None or len(observations) == 0:
72-
raise DataRequiredError("BilogY requires observations.")
7371
if adapter is not None and adapter._optimization_config is not None:
7472
# TODO @deriksson: Add support for relative outcome constraints
7573
self.metric_to_bound: dict[str, float] = {
@@ -117,12 +115,37 @@ def _reusable_transform(
117115
)
118116
return observation_data
119117

118+
def transform_experiment_data(
119+
self, experiment_data: ExperimentData
120+
) -> ExperimentData:
121+
obs_data = experiment_data.observation_data
122+
# This method applies match_ci_width to the corresponding columns.
123+
fac = norm.ppf(0.975)
124+
for metric, bound in self.metric_to_bound.items():
125+
mean = obs_data[("mean", metric)]
126+
obs_data[("mean", metric)] = bilog_transform(y=mean, bound=bound)
127+
sem = obs_data[("sem", metric)]
128+
if sem.isnull().all():
129+
# If SEM is NaN, we don't need to transform it.
130+
continue
131+
d = fac * sem
132+
width_asym = bilog_transform(y=mean + d, bound=bound) - bilog_transform(
133+
y=mean - d, bound=bound
134+
)
135+
obs_data[("sem", metric)] = width_asym / (2 * fac)
136+
return ExperimentData(
137+
arm_data=experiment_data.arm_data, observation_data=obs_data
138+
)
139+
120140

121141
def bilog_transform(y: npt.NDarray, bound: npt.NDarray) -> npt.NDarray:
122142
"""Bilog transform: f(y) = bound + sign(y - bound) * log(|y - bound| + 1)"""
123-
return bound + np.sign(y - bound) * np.log(np.abs(y - bound) + 1)
143+
diff = y - bound
144+
return bound + np.sign(diff) * np.log(np.abs(diff) + 1)
124145

125146

126147
def inv_bilog_transform(y: npt.NDarray, bound: npt.NDarray) -> npt.NDarray:
127148
"""Inverse bilog transform: f(y) = bound + sign(y - bound) * expm1(|y - bound|)"""
128-
return bound + np.sign(y - bound) * np.expm1((y - bound) * np.sign(y - bound))
149+
diff = y - bound
150+
sign = np.sign(diff)
151+
return bound + sign * np.expm1(diff * sign)

ax/adapter/transforms/tests/test_bilog_y.py

Lines changed: 65 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,18 @@
99
from __future__ import annotations
1010

1111
from copy import deepcopy
12+
from functools import partial
13+
from itertools import product
1214

13-
from ax.adapter.base import Adapter
15+
from ax.adapter.base import Adapter, DataLoaderConfig
16+
from ax.adapter.data_utils import extract_experiment_data
1417
from ax.adapter.transforms.bilog_y import bilog_transform, BilogY, inv_bilog_transform
15-
18+
from ax.adapter.transforms.log_y import match_ci_width
1619
from ax.core.observation import observations_from_data
17-
from ax.exceptions.core import DataRequiredError
1820
from ax.generators.base import Generator
1921
from ax.utils.common.testutils import TestCase
2022
from ax.utils.testing.core_stubs import get_branin_experiment
23+
from pandas.testing import assert_frame_equal, assert_series_equal
2124

2225

2326
class BilogYTest(TestCase):
@@ -29,8 +32,9 @@ def setUp(self) -> None:
2932
with_relative_constraint=True,
3033
)
3134
self.data = self.exp.fetch_data()
35+
self.bound = self.exp.optimization_config.outcome_constraints[1].bound
3236

33-
def get_mb(self) -> Adapter:
37+
def get_adapter(self) -> Adapter:
3438
return Adapter(
3539
search_space=self.exp.search_space,
3640
generator=Generator(),
@@ -39,15 +43,19 @@ def get_mb(self) -> Adapter:
3943
)
4044

4145
def test_Init(self) -> None:
42-
observations = observations_from_data(
43-
experiment=self.exp, data=self.exp.lookup_data()
44-
)
46+
# With adapter.
4547
t = BilogY(
4648
search_space=self.exp.search_space,
47-
observations=observations,
48-
adapter=self.get_mb(),
49+
adapter=self.get_adapter(),
4950
)
50-
self.assertEqual(t.metric_to_bound, {"branin_e": -0.25})
51+
self.assertEqual(t.metric_to_bound, {"branin_e": self.bound})
52+
53+
with self.subTest("With no adapter"):
54+
t = BilogY(
55+
search_space=self.exp.search_space,
56+
adapter=None,
57+
)
58+
self.assertEqual(t.metric_to_bound, {})
5159

5260
def test_Bilog(self) -> None:
5361
self.assertAlmostEqual(
@@ -78,8 +86,7 @@ def test_TransformUntransform(self) -> None:
7886
)
7987
t = BilogY(
8088
search_space=self.exp.search_space,
81-
observations=observations,
82-
adapter=self.get_mb(),
89+
adapter=self.get_adapter(),
8390
)
8491

8592
# Transform
@@ -138,51 +145,62 @@ def test_TransformUntransform(self) -> None:
138145
def test_TransformOptimizationConfig(self) -> None:
139146
t = BilogY(
140147
search_space=self.exp.search_space,
141-
observations=observations_from_data(
142-
experiment=self.exp, data=self.exp.lookup_data()
143-
),
144-
adapter=self.get_mb(),
148+
adapter=self.get_adapter(),
145149
)
146150
oc = self.exp.optimization_config
147151
# This should be a no-op
148152
new_oc = t.transform_optimization_config(optimization_config=oc)
149153
self.assertEqual(new_oc, oc)
150154

151155
def test_TransformSearchSpace(self) -> None:
152-
t = BilogY(
153-
search_space=self.exp.search_space,
154-
observations=observations_from_data(
155-
experiment=self.exp, data=self.exp.lookup_data()
156-
),
157-
adapter=self.get_mb(),
158-
)
156+
t = BilogY(search_space=self.exp.search_space, adapter=self.get_adapter())
159157
# This should be a no-op
160158
new_ss = t.transform_search_space(self.exp.search_space)
161159
self.assertEqual(new_ss, self.exp.search_space)
162160

163-
def test_AdapterIsNone(self) -> None:
164-
t = BilogY(
165-
search_space=self.exp.search_space,
166-
observations=observations_from_data(
167-
experiment=self.exp, data=self.exp.lookup_data()
161+
def test_transform_experiment_data(self) -> None:
162+
t = BilogY(search_space=self.exp.search_space, adapter=self.get_adapter())
163+
experiment_data = extract_experiment_data(
164+
experiment=self.exp, data_loader_config=DataLoaderConfig()
165+
)
166+
transformed_data = t.transform_experiment_data(
167+
experiment_data=deepcopy(experiment_data)
168+
)
169+
170+
# Check that arm data is identical.
171+
assert_frame_equal(transformed_data.arm_data, experiment_data.arm_data)
172+
173+
# Check that non-constraint metrics are unchanged.
174+
cols = list(product(("mean", "sem"), ("branin", "branin_d")))
175+
assert_frame_equal(
176+
transformed_data.observation_data[cols],
177+
experiment_data.observation_data[cols],
178+
)
179+
180+
# Check that `branin_e` has been transformed correctly.
181+
assert_series_equal(
182+
transformed_data.observation_data[("mean", "branin_e")],
183+
bilog_transform(
184+
experiment_data.observation_data[("mean", "branin_e")], bound=self.bound
168185
),
169-
adapter=None,
170-
)
171-
self.assertEqual(t.metric_to_bound, {})
172-
173-
def test_Raises(self) -> None:
174-
exp = get_branin_experiment(with_status_quo=True, with_batch=True)
175-
with self.assertRaisesRegex(DataRequiredError, "BilogY requires observations."):
176-
BilogY(
177-
search_space=exp.search_space,
178-
observations=observations_from_data(
179-
experiment=exp, data=exp.lookup_data()
180-
),
181-
adapter=None,
182-
)
183-
# Relative constraints should raise
184-
exp = get_branin_experiment(
185-
with_status_quo=True,
186-
with_completed_batch=True,
187-
with_relative_constraint=True,
186+
)
187+
# Sem is smaller than before.
188+
self.assertTrue(
189+
(
190+
transformed_data.observation_data[("sem", "branin_e")]
191+
< experiment_data.observation_data[("sem", "branin_e")]
192+
).all()
193+
)
194+
# Compare against transforming the old way.
195+
mean, var = match_ci_width(
196+
mean=experiment_data.observation_data[("mean", "branin_e")],
197+
variance=experiment_data.observation_data[("sem", "branin_e")] ** 2,
198+
transform=partial(bilog_transform, bound=self.bound),
199+
)
200+
assert_series_equal(
201+
transformed_data.observation_data[("mean", "branin_e")], mean
202+
)
203+
# Can't use assert_series_equal since the metadata is destroyed in var.
204+
self.assertTrue(
205+
transformed_data.observation_data[("sem", "branin_e")].equals(var**0.5)
188206
)

0 commit comments

Comments
 (0)