Skip to content

Commit 72a3a58

Browse files
sdaultonmeta-codesync[bot]
authored andcommitted
Fix incorrect hypervolume reference point for minimization objectives with inferred thresholds (#4970)
Summary: Pull Request resolved: #4970 `get_hypervolume_trace_of_outcomes_multi_objective` negates metric values for minimization objectives to convert them to maximization convention (line 784). However, when inferring objective thresholds from data, the code branched on `obj.minimize` to pick `max()` vs `min()` — not accounting for the already-negated data. This caused the inferred reference point to be the *best* observed value (not the worst), placing it above all data points and yielding a hypervolume of 0 for every trial. The fix separates the two cases: - **Explicit thresholds**: bound is in the original metric space, so negate for minimization (unchanged). - **Inferred thresholds**: data is already in maximization convention, so the worst value is simply `min()` — no further sign flip needed. Additionally, this diff changes how the reference point is inferred in `get_hypervolume_trace_of_outcomes_multi_objective`. Previously, the reference point was set to the worst observed objective values. Now, we use `infer_reference_point` from BoTorch to compute a scaled nadir point from the Pareto frontier of feasible observations. This approach scales the nadir by a factor (default 0.1) to ensure the reference point lies slightly below (worse than) the Pareto front, providing a more robust and theoretically grounded reference point for hypervolume computation. This bug affected `UtilityProgressionAnalysis` (and any caller of `get_trace`) for MOO experiments with minimization objectives that lack explicit objective thresholds. Reviewed By: mpolson64 Differential Revision: D94783465 fbshipit-source-id: c06209e10afadc562fb221dcae3a19019f7d4bdc
1 parent 1145c41 commit 72a3a58

File tree

3 files changed

+115
-14
lines changed

3 files changed

+115
-14
lines changed

ax/service/tests/test_best_point.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,19 @@ def test_get_trace(self) -> None:
9090
)
9191
self.assertEqual(get_trace(exp), [1, 1, 2, 9, 11, 11])
9292

93-
# W/o ObjectiveThresholds (infering ObjectiveThresholds from nadir point)
93+
# W/o ObjectiveThresholds (inferring ObjectiveThresholds from scaled nadir)
9494
assert_is_instance(
9595
exp.optimization_config, MultiObjectiveOptimizationConfig
9696
).objective_thresholds = []
97-
self.assertEqual(get_trace(exp), [0.0, 0.0, 2.0, 8.0, 11.0, 11.0])
97+
trace = get_trace(exp)
98+
# With inferred thresholds via scaled nadir, check trace properties:
99+
# - All values should be non-negative
100+
self.assertTrue(all(v >= 0.0 for v in trace))
101+
# - Trace should be non-decreasing (cumulative best)
102+
for i in range(1, len(trace)):
103+
self.assertGreaterEqual(trace[i], trace[i - 1])
104+
# - Final value should be positive (non-trivial HV)
105+
self.assertGreater(trace[-1], 0.0)
98106

99107
# Multi-objective w/ constraints.
100108
exp = get_experiment_with_observations(

ax/service/tests/test_best_point_utils.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,15 +168,50 @@ def test_get_hypervolume_trace_of_outcomes_multi_objective(self) -> None:
168168
optimization_config=optimization_config,
169169
use_cumulative_hv=True,
170170
)
171-
self.assertEqual(hvs, [0.0, 2.0, 2.0, 3.0])
171+
# Inferred ref point is (1.9, 1.9) from Pareto front {(2,3),(3,2)}.
172+
np.testing.assert_allclose(hvs, [0.0, 0.11, 0.11, 0.21], atol=1e-10)
172173

173174
with self.subTest("Non-cumulative HV"):
174175
hvs = get_hypervolume_trace_of_outcomes_multi_objective(
175176
df_wide=df_wide,
176177
optimization_config=optimization_config,
177178
use_cumulative_hv=False,
178179
)
179-
self.assertEqual(hvs, [0.0, 2.0, 0.0, 2.0])
180+
np.testing.assert_allclose(hvs, [0.0, 0.11, 0.0, 0.11], atol=1e-10)
181+
182+
def test_get_hypervolume_trace_minimization_inferred_thresholds(self) -> None:
183+
"""Test that inferred thresholds work correctly with minimization
184+
objectives. Regression test for a bug where the reference point was
185+
computed from already-negated data but treated as un-negated, causing
186+
the reference point to dominate all observations (yielding 0 HV).
187+
"""
188+
objective = MultiObjective(
189+
objectives=[
190+
Objective(metric=Metric("m1"), minimize=True),
191+
Objective(metric=Metric("m2"), minimize=True),
192+
],
193+
)
194+
optimization_config = MultiObjectiveOptimizationConfig(
195+
objective=objective,
196+
)
197+
df_wide = pd.DataFrame.from_records(
198+
[
199+
{"m1": 3.0, "m2": 1.0, "feasible": True},
200+
{"m1": 1.0, "m2": 3.0, "feasible": True},
201+
{"m1": 7.0, "m2": 7.0, "feasible": True},
202+
{"m1": 2.0, "m2": 2.0, "feasible": True},
203+
]
204+
)
205+
hvs = get_hypervolume_trace_of_outcomes_multi_objective(
206+
df_wide=df_wide.copy(),
207+
optimization_config=optimization_config,
208+
use_cumulative_hv=True,
209+
)
210+
# All HVs should be positive (before the fix, they were all 0.0)
211+
self.assertGreater(hvs[-1], 0.0)
212+
# The trace should be non-decreasing (cumulative best)
213+
for i in range(1, len(hvs)):
214+
self.assertGreaterEqual(hvs[i], hvs[i - 1])
180215

181216
def test_get_trace_by_arm_pull_from_data(self) -> None:
182217
objective = Objective(metric=Metric("m1"), minimize=False)
@@ -318,22 +353,26 @@ def test_get_trace_by_arm_pull_from_data(self) -> None:
318353
],
319354
),
320355
)
321-
# reference point inferred to be [1, 0]
356+
# reference point inferred via infer_reference_point on Pareto front
322357
with self.subTest("Multi-objective, cumulative"):
323358
result = get_trace_by_arm_pull_from_data(
324359
df=df, optimization_config=moo_opt_config, use_cumulative_best=True
325360
)
326361
self.assertEqual(len(result), 3)
327362
self.assertEqual(set(result.columns), {"trial_index", "arm_name", "value"})
328-
self.assertEqual(result["value"].tolist(), [0.0, 0.0, 2.0])
363+
np.testing.assert_allclose(
364+
result["value"].tolist(), [0.22, 0.22, 0.42], atol=1e-10
365+
)
329366

330367
with self.subTest("Multi-objective, non-cumulative"):
331368
result = get_trace_by_arm_pull_from_data(
332369
df=df, optimization_config=moo_opt_config, use_cumulative_best=False
333370
)
334371
self.assertEqual(len(result), 3)
335372
self.assertEqual(set(result.columns), {"trial_index", "arm_name", "value"})
336-
self.assertEqual(result["value"].tolist(), [0.0, 0.0, 2.0])
373+
np.testing.assert_allclose(
374+
result["value"].tolist(), [0.22, 0.0, 0.22], atol=1e-10
375+
)
337376

338377
@mock_botorch_optimize
339378
def test_best_from_model_prediction(self) -> None:

ax/service/utils/best_point.py

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
from ax.utils.preference.preference_utils import get_preference_adapter
5454
from botorch.utils.multi_objective.box_decompositions import DominatedPartitioning
5555
from botorch.utils.multi_objective.hypervolume import infer_reference_point
56+
from botorch.utils.multi_objective.pareto import is_non_dominated
5657
from numpy.typing import NDArray
5758
from pyre_extensions import assert_is_instance, none_throws
5859

@@ -738,7 +739,10 @@ def get_hypervolume_trace_of_outcomes_multi_objective(
738739
df_wide: Dataframe with columns ["feasible"] + relevant
739740
metrics. This can come from reshaping the data that comes from `Data.df`.
740741
optimization_config: A multi-objective optimization config with a
741-
`MultiObjective` (not a `ScalarizedObjective`).
742+
`MultiObjective` (not a `ScalarizedObjective`). When objective
743+
thresholds are not provided, they are inferred using
744+
``infer_reference_point`` on the Pareto frontier of the feasible
745+
observations.
742746
use_cumulative_hv: If True, the hypervolume returned is the cumulative
743747
hypervolume of the points in each row. Otherwise, this is the
744748
hypervolume of each point.
@@ -754,8 +758,21 @@ def get_hypervolume_trace_of_outcomes_multi_objective(
754758
... Objective(metric=Metric(name="m2"), minimize=False),
755759
... ]
756760
... ),
761+
... objective_thresholds=[
762+
... ObjectiveThreshold(
763+
... metric=Metric(name="m1"),
764+
... bound=0.0,
765+
... relative=False,
766+
... op=ComparisonOp.GEQ,
767+
... ),
768+
... ObjectiveThreshold(
769+
... metric=Metric(name="m2"),
770+
... bound=0.0,
771+
... relative=False,
772+
... op=ComparisonOp.GEQ,
773+
... ),
774+
... ],
757775
... )
758-
>>> # Objective threshols will be inferred to be zero
759776
>>> df_wide = pd.DataFrame.from_records(
760777
... [
761778
... {"m1": 0.0, "m2": 0.0, "feasible": True},
@@ -788,6 +805,8 @@ def get_hypervolume_trace_of_outcomes_multi_objective(
788805
threshold.metric.name: threshold
789806
for threshold in optimization_config.objective_thresholds
790807
}
808+
# First pass: collect explicit thresholds, mark missing ones with NaN.
809+
needs_inference = False
791810
for obj in objective.objectives:
792811
metric_name = obj.metric.name
793812
if metric_name in objective_thresholds_dict:
@@ -798,14 +817,49 @@ def get_hypervolume_trace_of_outcomes_multi_objective(
798817
"`Derelativize` the optimization config, or use "
799818
"`get_trace`."
800819
)
820+
# Explicit thresholds are in the original metric space, so negate
821+
# for minimization objectives to match the negated data.
801822
bound = threshold.bound
823+
objective_thresholds.append(-bound if obj.minimize else bound)
802824
else:
803-
metric_vals = df_wide[metric_name]
804-
bound = metric_vals.max() if obj.minimize else metric_vals.min()
805-
806-
objective_thresholds.append(-bound if obj.minimize else bound)
825+
needs_inference = True
826+
objective_thresholds.append(float("nan"))
827+
828+
if needs_inference:
829+
# Infer missing thresholds using infer_reference_point on the
830+
# observed Pareto frontier (data is already in maximization
831+
# convention after negating minimization objectives above).
832+
feasible_mask = df_wide["feasible"].to_numpy()
833+
Y_feasible = torch.from_numpy(
834+
df_wide.loc[feasible_mask, objective.metric_names].to_numpy().copy()
835+
).to(torch.double)
836+
if Y_feasible.shape[0] > 0:
837+
pareto_Y = Y_feasible[is_non_dominated(Y_feasible)]
838+
else:
839+
# No feasible points -- use all data as fallback.
840+
Y_all = torch.from_numpy(
841+
df_wide[objective.metric_names].to_numpy().copy()
842+
).to(torch.double)
843+
pareto_Y = Y_all[is_non_dominated(Y_all)]
844+
845+
max_ref_point = torch.tensor(objective_thresholds, dtype=torch.double)
846+
has_any_explicit = not max_ref_point.isnan().all()
847+
848+
inferred = infer_reference_point(
849+
pareto_Y=pareto_Y,
850+
max_ref_point=max_ref_point if has_any_explicit else None,
851+
scale=0.1,
852+
)
807853

808-
objective_thresholds = torch.tensor(objective_thresholds, dtype=torch.double)
854+
if has_any_explicit:
855+
# Replace NaN entries with inferred values.
856+
objective_thresholds = torch.where(
857+
max_ref_point.isnan(), inferred, max_ref_point
858+
)
859+
else:
860+
objective_thresholds = inferred
861+
else:
862+
objective_thresholds = torch.tensor(objective_thresholds, dtype=torch.double)
809863

810864
metrics_tensor = torch.from_numpy(df_wide[objective.metric_names].to_numpy().copy())
811865
return _compute_hv_trace(

0 commit comments

Comments
 (0)