Skip to content

Commit 273ff48

Browse files
Sunny Shenfacebook-github-bot
authored andcommitted
AddFeasibility transform
Summary: Transform that adds failure-awareness capability to Ax optimization. This transform enables Ax to learn from deterministic trial failures (ABANDONED trials) and avoid sampling similar parameter configurations that are likely to fail. It achieves this by: 1. Adding a "is_feasible" metric to experiment data based on trial status - ABANDONED trials get feasibility value of 0.0 (infeasible) - Other trials get feasibility value of 1.0 (feasible) 2. Adding a feasibility constraint to the optimization config - The constraint enforces P(is_feasible) >= threshold - This guides the acquisition function to avoid infeasible regions NOTE: We should maybe pick a different word than "feasibility" to not be confused with feasibility in the sense of not violating user-specified outcome constraints. Differential Revision: D85185246
1 parent 674c017 commit 273ff48

2 files changed

Lines changed: 460 additions & 0 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) Meta Platforms, Inc. and affiliates.
3+
#
4+
# This source code is licensed under the MIT license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
# pyre-strict
8+
9+
from __future__ import annotations
10+
11+
from logging import Logger
12+
from typing import TYPE_CHECKING
13+
14+
from ax.adapter.data_utils import ExperimentData
15+
from ax.adapter.transforms.base import Transform
16+
from ax.core.base_trial import TrialStatus
17+
from ax.core.metric import Metric
18+
from ax.core.observation import ObservationFeatures
19+
from ax.core.optimization_config import OptimizationConfig
20+
from ax.core.outcome_constraint import OutcomeConstraint
21+
from ax.core.search_space import SearchSpace
22+
from ax.core.types import ComparisonOp
23+
from ax.generators.types import TConfig
24+
from ax.utils.common.logger import get_logger
25+
26+
if TYPE_CHECKING:
27+
# import as module to make sphinx-autodoc-typehints happy
28+
from ax import adapter as adapter_module # noqa F401
29+
30+
logger: Logger = get_logger(__name__)
31+
32+
FEASIBILITY_METRIC_NAME = "is_feasible"
33+
34+
35+
class AddFeasibility(Transform):
36+
"""Transform that adds failure-awareness capability to Ax optimization.
37+
38+
This transform enables Ax to learn from deterministic trial failures (ABANDONED
39+
trials) and avoid sampling similar parameter configurations that are likely to
40+
fail. It achieves this by:
41+
42+
1. Adding a "is_feasible" metric to experiment data based on trial status
43+
- ABANDONED trials get feasibility value of 0.0 (infeasible)
44+
- Other trials get feasibility value of 1.0 (feasible)
45+
46+
2. Adding a feasibility constraint to the optimization config
47+
- The constraint enforces P(is_feasible) >= threshold
48+
- This guides the acquisition function to avoid infeasible regions
49+
50+
The transform only activates after observing a minimum number of ABANDONED trials
51+
to ensure there is sufficient data to model the failure region. Before reaching
52+
this threshold, the transform acts as a no-op.
53+
54+
Config options:
55+
feasibility_threshold: float (default 0.0)
56+
Minimum probability of feasibility required for new candidates.
57+
min_abandoned_trials: int (default 3)
58+
Minimum number of ABANDONED trials required before the transform activates.
59+
If fewer than this many ABANDONED trials exist, the transform does nothing.
60+
61+
Example usage:
62+
>>> transform = AddFeasibility(
63+
... config={
64+
... "feasibility_threshold": 0.8,
65+
... "min_abandoned_trials": 3,
66+
... }
67+
... )
68+
>>> # Transform adds feasibility constraint to optimization
69+
>>> new_opt_config = transform.transform_optimization_config(opt_config)
70+
>>> # Transform adds feasibility metric to data
71+
>>> transformed_data = transform.transform_experiment_data(exp_data)
72+
"""
73+
74+
def __init__(
75+
self,
76+
search_space: SearchSpace | None = None,
77+
experiment_data: ExperimentData | None = None,
78+
adapter: adapter_module.base.Adapter | None = None,
79+
config: TConfig | None = None,
80+
) -> None:
81+
super().__init__(
82+
search_space=search_space,
83+
experiment_data=experiment_data,
84+
adapter=adapter,
85+
config=config,
86+
)
87+
88+
def transform_experiment_data(
89+
self, experiment_data: ExperimentData
90+
) -> ExperimentData:
91+
"""Transform experiment data to add feasibility metrics.
92+
93+
Only activates after observing at least min_abandoned_trials ABANDONED trials.
94+
Returns the original data unchanged if this threshold is not met.
95+
96+
This method handles two types of ABANDONED trials:
97+
1. ABANDONED trials WITH data: These already exist in
98+
experiment_data and will get is_feasible = 0 added to their
99+
existing observations.
100+
2. ABANDONED trials WITHOUT data: These are missing from
101+
experiment_data (e.g., trials that failed due to metric errors).
102+
We add synthetic observations for these with is_feasible = 0 so
103+
the model can learn about infeasible regions.
104+
"""
105+
if self.adapter is None:
106+
raise ValueError(
107+
"Adapter must be provided for using feasibility constraints."
108+
)
109+
110+
adapter = self.adapter
111+
experiment = adapter._experiment
112+
113+
# Count ABANDONED trials using trials_by_status
114+
abandoned_trials = experiment.trials_by_status.get(TrialStatus.ABANDONED, [])
115+
abandoned_count = len(abandoned_trials)
116+
117+
# Check if we have enough ABANDONED trials to activate the transform
118+
raw_min_abandoned = self.config.get("min_abandoned_trials", 3)
119+
min_abandoned_trials = (
120+
int(raw_min_abandoned) if isinstance(raw_min_abandoned, (int, float)) else 3
121+
)
122+
if abandoned_count < min_abandoned_trials:
123+
logger.info(
124+
f"AddFeasibility transform inactive: only {abandoned_count} ABANDONED "
125+
f"trials observed (need {min_abandoned_trials}). Returning original data."
126+
)
127+
return experiment_data
128+
129+
# Proceed with adding feasibility metric
130+
obs_data = experiment_data.observation_data.copy(deep=True)
131+
arm_data = experiment_data.arm_data.copy(deep=True)
132+
133+
# Step 1: Add feasibility metric to existing observations
134+
trial_feasibilities = []
135+
for t_idx, _ in obs_data.index:
136+
trial_status = experiment.trials[t_idx].status
137+
is_feasible = float(trial_status != TrialStatus.ABANDONED)
138+
trial_feasibilities.append(is_feasible)
139+
140+
obs_data[("mean", FEASIBILITY_METRIC_NAME)] = trial_feasibilities
141+
obs_data[("sem", FEASIBILITY_METRIC_NAME)] = float("nan")
142+
143+
# Step 2: Identify ABANDONED trials that are NOT in the observation data
144+
trials_in_data = set(obs_data.index.get_level_values("trial_index").unique())
145+
abandoned_trials_without_data = [
146+
trial for trial in abandoned_trials if trial.index not in trials_in_data
147+
]
148+
149+
# Step 3: Add observations for ABANDONED trials without data
150+
if abandoned_trials_without_data:
151+
import pandas as pd
152+
153+
new_rows = []
154+
new_arm_rows = []
155+
156+
for trial in abandoned_trials_without_data:
157+
# Each trial can have multiple arms
158+
for arm in trial.arms:
159+
trial_idx = trial.index
160+
arm_name = arm.name
161+
162+
new_row_data = {
163+
"trial_index": trial_idx,
164+
"arm_name": arm_name,
165+
("mean", FEASIBILITY_METRIC_NAME): 0.0,
166+
("sem", FEASIBILITY_METRIC_NAME): float("nan"),
167+
}
168+
169+
# Add NaN values for all other metrics that exist in obs_data
170+
for col in obs_data.columns:
171+
if col not in [
172+
("mean", FEASIBILITY_METRIC_NAME),
173+
("sem", FEASIBILITY_METRIC_NAME),
174+
]:
175+
new_row_data[col] = float("nan")
176+
177+
new_rows.append(new_row_data)
178+
179+
# Also add to arm_data
180+
arm_row_data = dict(arm.parameters)
181+
metadata_raw = trial._get_candidate_metadata(arm.name)
182+
metadata = metadata_raw if metadata_raw is not None else {}
183+
if (
184+
"trial_completion_timestamp" not in metadata
185+
and trial._time_completed is not None
186+
):
187+
metadata["trial_completion_timestamp"] = (
188+
trial._time_completed.timestamp()
189+
)
190+
arm_row_data["metadata"] = metadata # pyre-ignore[6]
191+
new_arm_rows.append(
192+
{"trial_index": trial_idx, "arm_name": arm_name, **arm_row_data}
193+
)
194+
195+
if new_rows:
196+
new_obs_df = pd.DataFrame(new_rows)
197+
new_obs_df = new_obs_df.set_index(["trial_index", "arm_name"])
198+
199+
obs_data = pd.concat([obs_data, new_obs_df])
200+
201+
new_arm_df = pd.DataFrame(new_arm_rows)
202+
new_arm_df = new_arm_df.set_index(["trial_index", "arm_name"])
203+
arm_data = pd.concat([arm_data, new_arm_df])
204+
205+
logger.info(
206+
f"AddFeasibility: Added synthetic observations for "
207+
f"{len(abandoned_trials_without_data)} ABANDONED trials "
208+
"without data"
209+
)
210+
211+
logger.info(
212+
f"AddFeasibility transform active: {abandoned_count} ABANDONED trials "
213+
f"observed (threshold: {min_abandoned_trials})"
214+
)
215+
216+
return ExperimentData(
217+
arm_data=arm_data,
218+
observation_data=obs_data,
219+
)
220+
221+
def transform_optimization_config(
222+
self,
223+
optimization_config: OptimizationConfig,
224+
adapter: adapter_module.base.Adapter | None = None,
225+
fixed_features: ObservationFeatures | None = None,
226+
) -> OptimizationConfig:
227+
"""Transform optimization config to add feasibility constraint.
228+
229+
Only activates after observing at least min_abandoned_trials ABANDONED trials.
230+
Returns the original config unchanged if this threshold is not met.
231+
"""
232+
adapter = adapter or self.adapter
233+
if adapter is None:
234+
raise ValueError("Adapter must be provided for using feasibility.")
235+
236+
experiment = adapter._experiment
237+
238+
# Count ABANDONED trials using trials_by_status
239+
abandoned_trials = experiment.trials_by_status.get(TrialStatus.ABANDONED, [])
240+
abandoned_count = len(abandoned_trials)
241+
242+
# Check if we have enough ABANDONED trials to activate the transform
243+
raw_min_abandoned = self.config.get("min_abandoned_trials", 3)
244+
min_abandoned_trials = (
245+
int(raw_min_abandoned) if isinstance(raw_min_abandoned, (int, float)) else 3
246+
)
247+
if abandoned_count < min_abandoned_trials:
248+
logger.info(
249+
f"AddFeasibility transform inactive: only {abandoned_count} ABANDONED "
250+
f"trials observed (need {min_abandoned_trials}). Returning original config."
251+
)
252+
return optimization_config
253+
254+
# Proceed with adding feasibility constraint
255+
feasibility_metric = Metric(
256+
name=FEASIBILITY_METRIC_NAME,
257+
lower_is_better=False,
258+
)
259+
feasibility_constraint = OutcomeConstraint(
260+
metric=feasibility_metric,
261+
op=ComparisonOp.GEQ,
262+
bound=self.config.get("feasibility_threshold", 0.0), # pyre-ignore [6]
263+
relative=False,
264+
)
265+
266+
# Create a new list with existing constraints plus the feasibility constraint
267+
new_outcome_constraints = list(optimization_config.outcome_constraints)
268+
new_outcome_constraints.append(feasibility_constraint)
269+
270+
transformed_opt_config = optimization_config.clone_with_args(
271+
outcome_constraints=new_outcome_constraints,
272+
)
273+
274+
# Add feasibility metric to outcomes if not already present
275+
if feasibility_metric.name not in adapter.outcomes:
276+
adapter.outcomes.append(feasibility_metric.name)
277+
278+
logger.info(
279+
f"AddFeasibility constraint active: {abandoned_count} ABANDONED trials "
280+
f"observed (threshold: {min_abandoned_trials})"
281+
)
282+
283+
return transformed_opt_config

0 commit comments

Comments
 (0)