diff --git a/ax/adapter/adapter_utils.py b/ax/adapter/adapter_utils.py index 1a622ba8907..2484c56fb1f 100644 --- a/ax/adapter/adapter_utils.py +++ b/ax/adapter/adapter_utils.py @@ -41,12 +41,16 @@ from ax.core.types import TBounds, TCandidateMetadata, TNumeric from ax.exceptions.core import DataRequiredError, UserInputError from ax.generators.torch.botorch_moo_utils import ( - get_weighted_mc_objective_and_objective_thresholds, + get_weighted_mc_objective, pareto_frontier_evaluator, ) from ax.utils.common.constants import Keys from ax.utils.common.hash_utils import get_current_lilo_hash from ax.utils.common.logger import get_logger +from ax.utils.common.sympy import ( + extract_metric_weights_from_objective_expr, + parse_objective_expression, +) from ax.utils.common.typeutils import ( assert_is_instance_of_tuple, assert_is_instance_optional, @@ -208,15 +212,21 @@ def extract_objective_thresholds( outcomes: list[str], metric_name_to_signature: Mapping[str, str], ) -> npt.NDArray | None: - """Extracts objective thresholds' values, in the order of `outcomes`. + """Extracts objective thresholds' values, in the order of objectives. + + Will return None if no objective thresholds or if the objective is single- + objective. Otherwise the extracted array will have length ``n_objectives`` + (matching the rows of the objective weight matrix). - Will return None if no objective thresholds, otherwise the extracted tensor - will be the same length as `outcomes`. + Objectives that do not have a corresponding objective threshold will be + given a threshold of NaN. We will later infer appropriate threshold values + for those objectives. - Outcomes that are not part of an objective and the objectives that do no have - a corresponding objective threshold will be given a threshold of NaN. We will - later infer appropriate threshold values for the objectives that are given a - threshold of NaN. + The returned thresholds are maximization-aligned: for minimize objectives, + the threshold is negated. E.g., an outcome we want to maximize with a + threshold of at least 5 returns 5. An outcome we want to minimize with a + threshold of no more than 5 returns -5, since we maximize the negative of + the outcome internally. Args: objective_thresholds: Objective thresholds to extract values from. @@ -225,7 +235,7 @@ def extract_objective_thresholds( metric_name_to_signature: Mapping from metric names to signatures. Returns: - (n,) array of thresholds + ``(n_objectives,)`` array of maximization-aligned thresholds, or None. """ if len(objective_thresholds) == 0: return None @@ -250,11 +260,23 @@ def extract_objective_thresholds( f"Got {objective_thresholds=} and {objective=}." ) - # Initialize these to be NaN to make sure that objective thresholds for - # non-objective metrics are never used. - obj_t = np.full(len(outcomes), float("nan")) - for metric, threshold in objective_threshold_dict.items(): - obj_t[outcomes.index(metric)] = threshold + if not objective.is_multi_objective: + # Single objective — thresholds not applicable. + return None + + parsed = parse_objective_expression(objective.expression) + sub_exprs = parsed if isinstance(parsed, tuple) else (parsed,) + n_objectives = len(sub_exprs) + obj_t = np.full(n_objectives, float("nan")) + for i, sub_expr in enumerate(sub_exprs): + sub_mw = extract_metric_weights_from_objective_expr(sub_expr) + if len(sub_mw) > 1: + continue # Scalarized sub-objective — NaN, will be inferred later. + name, weight = sub_mw[0] + sig = metric_name_to_signature[name] + if sig in objective_threshold_dict: + sign = 1.0 if weight > 0 else -1.0 + obj_t[i] = sign * objective_threshold_dict[sig] return obj_t @@ -769,10 +791,8 @@ def pareto_frontier( if obj_t is None: return frontier_observations - # Apply appropriate weights and thresholds - obj, obj_t = get_weighted_mc_objective_and_objective_thresholds( - objective_weights=obj_w, objective_thresholds=obj_t - ) + # Apply appropriate weights + obj = get_weighted_mc_objective(objective_weights=obj_w) f_t = obj(f) # Compute individual hypervolumes by taking the difference between the observation @@ -937,15 +957,13 @@ def hypervolume( dtype=torch.bool, device=f.device, ) - # Apply appropriate weights and thresholds - obj, obj_t = get_weighted_mc_objective_and_objective_thresholds( - objective_weights=obj_w, objective_thresholds=none_throws(obj_t) - ) + # Apply appropriate weights + obj = get_weighted_mc_objective(objective_weights=obj_w) f_t = obj(f) obj_mask = (obj_w != 0).any(dim=0).nonzero().view(-1) selected_metrics_mask = selected_metrics_mask[obj_mask] f_t = f_t[:, selected_metrics_mask] - obj_t = obj_t[selected_metrics_mask] + obj_t = none_throws(obj_t)[selected_metrics_mask] bd = DominatedPartitioning(ref_point=obj_t, Y=f_t) return bd.compute_hypervolume().item() diff --git a/ax/adapter/tests/test_torch_moo_adapter.py b/ax/adapter/tests/test_torch_moo_adapter.py index 7d01c6a893f..b33a1e4e66b 100644 --- a/ax/adapter/tests/test_torch_moo_adapter.py +++ b/ax/adapter/tests/test_torch_moo_adapter.py @@ -199,10 +199,10 @@ def helper_test_pareto_frontier( ) ) self.assertTrue(obj_t is not None) + # Thresholds are now (n_objectives,) and maximization-aligned. + # LEQ thresholds with bound=5.0 become -5.0 after sign flip. self.assertTrue( - torch.equal( - none_throws(obj_t)[:2], torch.full((2,), 5.0, dtype=torch.double) - ) + torch.equal(none_throws(obj_t), torch.full((2,), -5.0, dtype=torch.double)) ) observed_frontier2 = pareto_frontier( adapter=adapter, diff --git a/ax/adapter/tests/test_utils.py b/ax/adapter/tests/test_utils.py index 061783c0a3f..b88a79a5a9d 100644 --- a/ax/adapter/tests/test_utils.py +++ b/ax/adapter/tests/test_utils.py @@ -140,7 +140,7 @@ def test_extract_objective_thresholds(self) -> None: for i, name in enumerate(outcomes[:3]) ] - # None of no thresholds + # None if no thresholds self.assertIsNone( extract_objective_thresholds( objective_thresholds=[], @@ -150,17 +150,17 @@ def test_extract_objective_thresholds(self) -> None: ) ) - # Working case + # Working case: 3 objectives (all maximize), shape is (3,) obj_t = extract_objective_thresholds( objective_thresholds=objective_thresholds, objective=objective, outcomes=outcomes, metric_name_to_signature=metric_name_to_signature, ) - expected_obj_t_not_nan = np.array([2.0, 3.0, 4.0]) - self.assertTrue(np.array_equal(obj_t[:3], expected_obj_t_not_nan[:3])) - self.assertTrue(np.isnan(obj_t[-1])) - self.assertEqual(obj_t.shape[0], 4) + # All maximize, so thresholds are unchanged (sign = +1). + expected_obj_t = np.array([2.0, 3.0, 4.0]) + self.assertTrue(np.array_equal(obj_t, expected_obj_t)) + self.assertEqual(obj_t.shape[0], 3) # Returns NaN for objectives without a threshold. obj_t = extract_objective_thresholds( @@ -169,8 +169,9 @@ def test_extract_objective_thresholds(self) -> None: outcomes=outcomes, metric_name_to_signature=metric_name_to_signature, ) - self.assertTrue(np.array_equal(obj_t[:2], expected_obj_t_not_nan[:2])) - self.assertTrue(np.isnan(obj_t[-2:]).all()) + self.assertTrue(np.array_equal(obj_t[:2], expected_obj_t[:2])) + self.assertTrue(np.isnan(obj_t[2])) + self.assertEqual(obj_t.shape[0], 3) # Fails if a threshold does not have a corresponding metric. objective2 = Objective(expression="m1") @@ -182,16 +183,48 @@ def test_extract_objective_thresholds(self) -> None: metric_name_to_signature=metric_name_to_signature, ) - # Works with a single objective, single threshold + # Single objective returns None. + self.assertIsNone( + extract_objective_thresholds( + objective_thresholds=objective_thresholds[:1], + objective=objective2, + outcomes=outcomes, + metric_name_to_signature=metric_name_to_signature, + ) + ) + + # Maximize-alignment: minimize objectives get negated thresholds. + objective_with_min = MultiObjective( + objectives=[ + Objective(metric=Metric("m1"), minimize=False), + Objective(metric=Metric("m2"), minimize=True), + ] + ) + obj_thresholds_for_min = [ + ObjectiveThreshold( + metric=Metric("m1"), + op=ComparisonOp.LEQ, + bound=2.0, + relative=False, + ), + ObjectiveThreshold( + metric=Metric("m2"), + op=ComparisonOp.LEQ, + bound=3.0, + relative=False, + ), + ] obj_t = extract_objective_thresholds( - objective_thresholds=objective_thresholds[:1], - objective=objective2, + objective_thresholds=obj_thresholds_for_min, + objective=objective_with_min, outcomes=outcomes, metric_name_to_signature=metric_name_to_signature, ) + # m1 maximize: sign=+1, threshold=2.0 → 2.0 + # m2 minimize: sign=-1, threshold=3.0 → -3.0 + self.assertEqual(obj_t.shape[0], 2) self.assertEqual(obj_t[0], 2.0) - self.assertTrue(np.all(np.isnan(obj_t[1:]))) - self.assertEqual(obj_t.shape[0], 4) + self.assertEqual(obj_t[1], -3.0) # Fails if relative objective_thresholds[2] = ObjectiveThreshold( diff --git a/ax/adapter/torch.py b/ax/adapter/torch.py index 479a54543d0..ddfdb3e4a67 100644 --- a/ax/adapter/torch.py +++ b/ax/adapter/torch.py @@ -1153,12 +1153,14 @@ def _untransform_objective_thresholds( """ obj_indices, obj_weights = extract_objectives(objective_weights) thresholds = [] - for idx, w in zip(obj_indices, obj_weights): + for i, (idx, w) in enumerate(zip(obj_indices, obj_weights)): sign = torch.sign(w) + # Thresholds are maximization-aligned; undo sign flip to get raw bound. + raw_bound = float(sign * objective_thresholds[i].item()) thresholds.append( ObjectiveThreshold( metric=opt_config_metrics[self.outcomes[idx]], - bound=float(objective_thresholds[idx].item()), + bound=raw_bound, relative=False, op=ComparisonOp.LEQ if sign < 0 else ComparisonOp.GEQ, ) diff --git a/ax/generators/tests/test_botorch_moo_utils.py b/ax/generators/tests/test_botorch_moo_utils.py index a6583c80558..4af791486ef 100644 --- a/ax/generators/tests/test_botorch_moo_utils.py +++ b/ax/generators/tests/test_botorch_moo_utils.py @@ -11,12 +11,11 @@ from unittest import mock from warnings import catch_warnings, simplefilter -import numpy as np import torch from ax.core.search_space import SearchSpaceDigest from ax.generators.torch.botorch_modular.generator import BoTorchGenerator from ax.generators.torch.botorch_moo_utils import ( - get_weighted_mc_objective_and_objective_thresholds, + get_weighted_mc_objective, infer_objective_thresholds, pareto_frontier_evaluator, ) @@ -68,7 +67,8 @@ def setUp(self) -> None: ] ) self.Yvar = torch.zeros(5, 3) - self.objective_thresholds = torch.tensor([0.5, 1.5, float("nan")]) + # Thresholds are (n_objectives,) in maximization-aligned space. + self.objective_thresholds = torch.tensor([0.5, 1.5]) self.objective_weights = torch.tensor([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) def test_pareto_frontier_raise_error_when_missing_data(self) -> None: @@ -110,11 +110,12 @@ def test_pareto_frontier_evaluator_raw(self) -> None: self.assertAllClose(expected_cov, cov) self.assertTrue(torch.equal(torch.arange(2, 5), indx)) - # Change objective_weights so goal is to minimize b + # Change objective_weights so goal is to minimize b. + # Thresholds in maximization-aligned space: [0.5, -1.5]. Y, cov, indx = pareto_frontier_evaluator( model=model, objective_weights=torch.tensor([[1.0, 0.0, 0.0], [0.0, -1.0, 0.0]]), - objective_thresholds=self.objective_thresholds, + objective_thresholds=torch.tensor([0.5, -1.5]), Y=self.Y, Yvar=Yvar, ) @@ -213,19 +214,13 @@ def test_pareto_frontier_evaluator_with_nan(self) -> None: class BotorchMOOUtilsTest(TestCase): - def test_get_weighted_mc_objective_and_objective_thresholds(self) -> None: + def test_get_weighted_mc_objective(self) -> None: objective_weights = torch.tensor([[0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) - objective_thresholds = torch.arange(4, dtype=torch.float) - ( - weighted_obj, - new_obj_thresholds, - ) = get_weighted_mc_objective_and_objective_thresholds( + weighted_obj = get_weighted_mc_objective( objective_weights=objective_weights, - objective_thresholds=objective_thresholds, ) self.assertTrue(torch.equal(weighted_obj.weights, torch.tensor([1.0, 1.0]))) self.assertEqual(weighted_obj.outcomes.tolist(), [1, 3]) - self.assertTrue(torch.equal(new_obj_thresholds, objective_thresholds[[1, 3]])) # test infer objective thresholds alone @mock.patch( # pyre-ignore @@ -255,6 +250,10 @@ def test_infer_objective_thresholds(self, _, cuda: bool = False) -> None: objective_weights = torch.tensor( [[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0]], **tkwargs ) + # Expected: infer_reference_point returns (n_objectives,) in + # maximization-aligned space. With pareto_Y=[[-9, -3]] and + # scale=0.1, the result is [-9.9, -3.3]. + expected_thresholds = torch.tensor([-9.9, -3.3], **tkwargs) with ExitStack() as es: _mock_infer_reference_point = es.enter_context( mock.patch( @@ -282,10 +281,9 @@ def test_infer_objective_thresholds(self, _, cuda: bool = False) -> None: torch.tensor([[-9.0, -3.0]], **tkwargs), ) ) - self.assertTrue( - torch.equal(obj_thresholds[:2], torch.tensor([9.9, 3.3], **tkwargs)) - ) - self.assertTrue(np.isnan(obj_thresholds[2].item())) + # Result is (n_objectives,) maximization-aligned. + self.assertEqual(obj_thresholds.shape[0], 2) + self.assertTrue(torch.equal(obj_thresholds, expected_thresholds)) # test subset_model without subset_idcs with mock.patch.object(model, "posterior", return_value=posterior): @@ -295,10 +293,8 @@ def test_infer_objective_thresholds(self, _, cuda: bool = False) -> None: outcome_constraints=outcome_constraints, X_observed=Xs[0], ) - self.assertTrue( - torch.equal(obj_thresholds[:2], torch.tensor([9.9, 3.3], **tkwargs)) - ) - self.assertTrue(np.isnan(obj_thresholds[2].item())) + self.assertEqual(obj_thresholds.shape[0], 2) + self.assertTrue(torch.equal(obj_thresholds, expected_thresholds)) # test passing subset_idcs subset_idcs = torch.tensor( [0, 1], dtype=torch.long, device=tkwargs["device"] @@ -312,10 +308,8 @@ def test_infer_objective_thresholds(self, _, cuda: bool = False) -> None: X_observed=Xs[0], subset_idcs=subset_idcs, ) - self.assertTrue( - torch.equal(obj_thresholds[:2], torch.tensor([9.9, 3.3], **tkwargs)) - ) - self.assertTrue(np.isnan(obj_thresholds[2].item())) + self.assertEqual(obj_thresholds.shape[0], 2) + self.assertTrue(torch.equal(obj_thresholds, expected_thresholds)) # test without subsetting (e.g. if there are # 3 metrics for 2 objectives + 1 outcome constraint) outcome_constraints = ( @@ -350,10 +344,8 @@ def test_infer_objective_thresholds(self, _, cuda: bool = False) -> None: X_observed=Xs[0], outcome_constraints=outcome_constraints, ) - self.assertTrue( - torch.equal(obj_thresholds[:2], torch.tensor([9.9, 3.3], **tkwargs)) - ) - self.assertTrue(np.isnan(obj_thresholds[2].item())) + self.assertEqual(obj_thresholds.shape[0], 2) + self.assertTrue(torch.equal(obj_thresholds, expected_thresholds)) def test_infer_objective_thresholds_cuda(self) -> None: if torch.cuda.is_available(): diff --git a/ax/generators/tests/test_torch_model_utils.py b/ax/generators/tests/test_torch_model_utils.py index dcef9c9fb0e..e82d0542f46 100644 --- a/ax/generators/tests/test_torch_model_utils.py +++ b/ax/generators/tests/test_torch_model_utils.py @@ -17,7 +17,6 @@ from botorch.models.model import ModelList from botorch.models.model_list_gp_regression import ModelListGP from botorch.models.multitask import MultiTaskGP -from pyre_extensions import none_throws from torch import Tensor @@ -38,7 +37,6 @@ def setUp(self) -> None: super().setUp() self.x = torch.zeros(1, 1) self.y = torch.rand(1, 2) - self.obj_t = torch.rand(2) self.model = SingleTaskGP(self.x, self.y) self.obj_weights = torch.tensor([[1.0, 0.0]]) @@ -48,9 +46,7 @@ def test_can_subset(self) -> None: model_sub = subset_model_results.model obj_weights_sub = subset_model_results.objective_weights ocs_sub = subset_model_results.outcome_constraints - obj_t_sub = subset_model_results.objective_thresholds self.assertIsNone(ocs_sub) - self.assertIsNone(obj_t_sub) self.assertEqual(model_sub.num_outputs, 1) self.assertTrue(torch.equal(obj_weights_sub, torch.tensor([[1.0]]))) @@ -60,9 +56,7 @@ def test_cannot_subset(self) -> None: model_sub = subset_model_results.model obj_weights_sub = subset_model_results.objective_weights ocs_sub = subset_model_results.outcome_constraints - obj_t_sub = subset_model_results.objective_thresholds self.assertIsNone(ocs_sub) - self.assertIsNone(obj_t_sub) self.assertIs(model_sub, self.model) # check identity self.assertIs(obj_weights_sub, obj_weights) # check identity self.assertTrue(torch.equal(subset_model_results.indices, torch.tensor([0, 1]))) @@ -73,9 +67,7 @@ def test_with_outcome_constraints_can_subset(self) -> None: model_sub = subset_model_results.model obj_weights_sub = subset_model_results.objective_weights ocs_sub = subset_model_results.outcome_constraints - obj_t_sub = subset_model_results.objective_thresholds self.assertEqual(model_sub.num_outputs, 1) - self.assertIsNone(obj_t_sub) self.assertTrue(torch.equal(obj_weights_sub, torch.tensor([[1.0]]))) # pyre-fixme[16]: Optional type has no attribute `__getitem__`. self.assertTrue(torch.equal(ocs_sub[0], torch.tensor([[1.0]]))) @@ -88,47 +80,11 @@ def test_with_outcome_constraints_cannot_subset(self) -> None: model_sub = subset_model_results.model obj_weights_sub = subset_model_results.objective_weights ocs_sub = subset_model_results.outcome_constraints - obj_t_sub = subset_model_results.objective_thresholds self.assertIs(model_sub, self.model) # check identity - self.assertIsNone(obj_t_sub) self.assertIs(obj_weights_sub, self.obj_weights) # check identity self.assertIs(ocs_sub, ocs) # check identity self.assertTrue(torch.equal(subset_model_results.indices, torch.tensor([0, 1]))) - def test_with_obj_thresholds_cannot_subset(self) -> None: - # test w/ objective thresholds, cannot subset - ocs = (torch.tensor([[0.0, 1.0]]), torch.tensor([1.0])) - subset_model_results = subset_model( - self.model, self.obj_weights, ocs, self.obj_t - ) - model_sub = subset_model_results.model - obj_weights_sub = subset_model_results.objective_weights - ocs_sub = subset_model_results.outcome_constraints - obj_t_sub = subset_model_results.objective_thresholds - self.assertIs(model_sub, self.model) # check identity - self.assertIs(self.obj_t, obj_t_sub) - self.assertIs(obj_weights_sub, self.obj_weights) # check identity - self.assertTrue(torch.equal(subset_model_results.indices, torch.tensor([0, 1]))) - self.assertIs(ocs_sub, ocs) # check identity - - def test_with_obj_thresholds_can_subset(self) -> None: - # test w/ objective thresholds, can subset - ocs = (torch.tensor([[1.0, 0.0]]), torch.tensor([1.0])) - subset_model_results = subset_model( - self.model, self.obj_weights, ocs, self.obj_t - ) - model_sub = subset_model_results.model - obj_weights_sub = subset_model_results.objective_weights - ocs_sub = none_throws(subset_model_results.outcome_constraints) - obj_t_sub = subset_model_results.objective_thresholds - self.assertTrue(torch.equal(subset_model_results.indices, torch.tensor([0]))) - self.assertEqual(model_sub.num_outputs, 1) - self.assertTrue(torch.equal(obj_weights_sub, torch.tensor([[1.0]]))) - # pyre-fixme[6]: For 1st param expected `Tensor` but got `Optional[Tensor]`. - self.assertTrue(torch.equal(obj_t_sub, self.obj_t[:1])) - self.assertTrue(torch.equal(ocs_sub[0], torch.tensor([[1.0]]))) - self.assertTrue(torch.equal(ocs_sub[1], torch.tensor([1.0]))) - def test_unsupported(self) -> None: yvar = torch.ones(1, 2) model = SingleTaskGP(train_X=self.x, train_Y=self.y, train_Yvar=yvar) diff --git a/ax/generators/torch/botorch_modular/acquisition.py b/ax/generators/torch/botorch_modular/acquisition.py index c07b36caedb..1777585b75c 100644 --- a/ax/generators/torch/botorch_modular/acquisition.py +++ b/ax/generators/torch/botorch_modular/acquisition.py @@ -230,8 +230,8 @@ def __init__( self.X_pending: Tensor | None = X_pending self.X_observed: Tensor | None = X_observed - # Store objective thresholds for all outcomes (including non-objectives). - self._full_objective_thresholds: Tensor | None = ( + # Store (n_objectives,) maximization-aligned objective thresholds. + self._objective_thresholds: Tensor | None = ( torch_opt_config.objective_thresholds ) self._full_objective_weights: Tensor = torch_opt_config.objective_weights @@ -240,12 +240,10 @@ def __init__( self._model, self._objective_weights, self._outcome_constraints, - self._objective_thresholds, ) = self._subset_model( model=surrogate.model, objective_weights=torch_opt_config.objective_weights, outcome_constraints=torch_opt_config.outcome_constraints, - objective_thresholds=torch_opt_config.objective_thresholds, ) self._update_objective_thresholds(torch_opt_config=torch_opt_config) self._set_preference_model(torch_opt_config=torch_opt_config) @@ -269,16 +267,14 @@ def _subset_model( model: Model, objective_weights: Tensor, outcome_constraints: tuple[Tensor, Tensor] | None = None, - objective_thresholds: Tensor | None = None, - ) -> tuple[Model, Tensor, tuple[Tensor, Tensor] | None, Tensor | None]: + ) -> tuple[Model, Tensor, tuple[Tensor, Tensor] | None]: if not self._should_subset_model: - return model, objective_weights, outcome_constraints, objective_thresholds + return model, objective_weights, outcome_constraints # Otherwise, subset subset_model_results = subset_model( model=model, objective_weights=objective_weights, outcome_constraints=outcome_constraints, - objective_thresholds=objective_thresholds, ) if self._subset_idcs is None: self._subset_idcs = subset_model_results.indices @@ -290,7 +286,6 @@ def _subset_model( subset_model_results.model, subset_model_results.objective_weights, subset_model_results.outcome_constraints, - subset_model_results.objective_thresholds, ) def _update_objective_thresholds(self, torch_opt_config: TorchOptConfig) -> None: @@ -308,27 +303,20 @@ def _update_objective_thresholds(self, torch_opt_config: TorchOptConfig) -> None if not ( torch_opt_config.is_moo and ( - self._full_objective_thresholds is None - or self._full_objective_thresholds[torch_opt_config.outcome_mask] - .isnan() - .any() + self._objective_thresholds is None + or self._objective_thresholds.isnan().any() ) and self.X_observed is not None ): return try: - self._full_objective_thresholds = infer_objective_thresholds( + self._objective_thresholds = infer_objective_thresholds( model=self._model, objective_weights=self._full_objective_weights, X_observed=self.X_observed, outcome_constraints=torch_opt_config.outcome_constraints, subset_idcs=self._subset_idcs, - objective_thresholds=self._full_objective_thresholds, - ) - self._objective_thresholds = ( - none_throws(self._full_objective_thresholds)[self._subset_idcs] - if self._subset_idcs is not None - else self._full_objective_thresholds + objective_thresholds=self._objective_thresholds, ) except (AxError, BotorchError) as e: logger.warning( @@ -417,11 +405,13 @@ def _construct_botorch_acquisition( constraint_transforms = constraint_transforms + threshold_transforms elif threshold_transforms is not None: constraint_transforms = threshold_transforms + # Pass thresholds directly as ref_point. Our thresholds are already + # maximization-aligned, so no further transformation is needed. input_constructor_kwargs = { "model": model, "X_baseline": self.X_observed, "X_pending": self.X_pending, - "objective_thresholds": self._objective_thresholds, + "ref_point": self._objective_thresholds, "constraints": constraint_transforms, "constraints_tuple": self._outcome_constraints, "objective": objective, @@ -476,11 +466,8 @@ def device(self) -> torch.device | None: @property def objective_thresholds(self) -> Tensor | None: - """The objective thresholds for all outcomes. - - For non-objective outcomes, the objective thresholds are nans. - """ - return self._full_objective_thresholds + """The ``(n_objectives,)`` maximization-aligned objective thresholds.""" + return self._objective_thresholds @property def objective_weights(self) -> Tensor | None: diff --git a/ax/generators/torch/botorch_modular/multi_acquisition.py b/ax/generators/torch/botorch_modular/multi_acquisition.py index 873bed83ef4..b4b589e93c0 100644 --- a/ax/generators/torch/botorch_modular/multi_acquisition.py +++ b/ax/generators/torch/botorch_modular/multi_acquisition.py @@ -56,7 +56,7 @@ def _instantiate_acquisition( if len(models) == self.n: self.acq_function_sequence = [] for model in models: - model, _, _, _ = self._subset_model( + model, _, _ = self._subset_model( model=model, objective_weights=self._full_objective_weights ) acqf = self._construct_botorch_acquisition( diff --git a/ax/generators/torch/botorch_modular/utils.py b/ax/generators/torch/botorch_modular/utils.py index f37cf7f8b4c..1aff882c079 100644 --- a/ax/generators/torch/botorch_modular/utils.py +++ b/ax/generators/torch/botorch_modular/utils.py @@ -353,37 +353,28 @@ def _objective_threshold_to_outcome_constraints( ) -> tuple[Tensor, Tensor]: """Convert objective thresholds to outcome constraint format ``(A, b)``. - For each objective ``i`` with nonzero weight ``w_i`` and non-NaN threshold - ``t_i``, the constraint is ``w_i * Y_i >= w_i * t_i``, which is equivalent - to ``-w_i * Y_i <= -w_i * t_i`` in the standard ``A f(x) <= b`` format. + For each objective ``i`` with non-NaN threshold ``t_i``, the constraint is + that the objective value must exceed the threshold in the maximization- + aligned space. Since thresholds are already maximization-aligned, the + constraint is: ``objective_weights[i] @ Y >= t_i``, which in standard + ``A f(x) <= b`` format becomes ``-objective_weights[i] @ Y <= -t_i``. Args: objective_weights: A ``(n_objectives, n_outcomes)`` tensor of objective weights. - objective_thresholds: A ``m``-dim tensor of objective thresholds. + objective_thresholds: A ``(n_objectives,)`` tensor of maximization- + aligned objective thresholds. Returns: A tuple ``(A, b)`` of outcome constraint tensors. """ - obj_idcs, obj_weights = extract_objectives(objective_weights) # Filter to objectives with non-NaN thresholds. Objective thresholds # can contain NaNs if the objective thresholds were inferred, but # there are no feasible points. In that case, # qLogProbabilityOfFeasibility is used. - non_nan_mask = ~objective_thresholds[obj_idcs].isnan() - obj_idcs = obj_idcs[non_nan_mask] - obj_weights = obj_weights[non_nan_mask] - m = objective_weights.shape[1] - k = len(obj_idcs) - A = torch.zeros( - k, m, dtype=objective_weights.dtype, device=objective_weights.device - ) - b = torch.zeros( - k, 1, dtype=objective_weights.dtype, device=objective_weights.device - ) - for i, (idx, w) in enumerate(zip(obj_idcs, obj_weights)): - A[i, idx] = -w - b[i] = -w * objective_thresholds[idx] + non_nan_mask = ~objective_thresholds.isnan() + A = -objective_weights[non_nan_mask] + b = -objective_thresholds[non_nan_mask].unsqueeze(-1) return A, b @@ -470,15 +461,15 @@ def choose_botorch_acqf_class( obj_weights = torch_opt_config.objective_weights obj_thresholds = none_throws(torch_opt_config.objective_thresholds) obj_idcs, weights = extract_objectives(obj_weights) - non_nan_mask = ~obj_thresholds[obj_idcs].isnan() + non_nan_mask = ~obj_thresholds.isnan() if non_nan_mask.any(): - # Check: w_i * Y_i >= w_i * t_i for all objectives i. + # Convert observations to maximization-aligned objective + # values and compare against thresholds (already aligned). weighted_Y = dataset.Y[:, obj_idcs] * weights - weighted_t = obj_thresholds[obj_idcs] * weights is_feasible = is_feasible & ( - (weighted_Y[:, non_nan_mask] >= weighted_t[non_nan_mask]).all( - dim=-1 - ) + ( + weighted_Y[:, non_nan_mask] >= obj_thresholds[non_nan_mask] + ).all(dim=-1) ) if not is_feasible.any().item(): diff --git a/ax/generators/torch/botorch_moo_utils.py b/ax/generators/torch/botorch_moo_utils.py index 1b4f8a1cb08..9db0b642611 100644 --- a/ax/generators/torch/botorch_moo_utils.py +++ b/ax/generators/torch/botorch_moo_utils.py @@ -50,32 +50,22 @@ ) -def get_weighted_mc_objective_and_objective_thresholds( - objective_weights: Tensor, objective_thresholds: Tensor -) -> tuple[WeightedMCMultiOutputObjective, Tensor]: - r"""Construct weighted objective and apply the weights to objective thresholds. +def get_weighted_mc_objective( + objective_weights: Tensor, +) -> WeightedMCMultiOutputObjective: + r"""Construct a weighted MC multi-output objective from objective weights. Args: objective_weights: A ``(n_objectives, n_outcomes)`` tensor of objective weights. - objective_thresholds: A tensor containing thresholds forming a reference point - from which to calculate pareto frontier hypervolume. Points that do not - dominate the objective_thresholds contribute nothing to hypervolume. Returns: - A two-element tuple with the objective and objective thresholds: - - - The objective - - The objective thresholds - + A WeightedMCMultiOutputObjective. """ outcome_indices, weights = extract_objectives(objective_weights) - objective_thresholds = objective_thresholds[outcome_indices] - objective = WeightedMCMultiOutputObjective( + return WeightedMCMultiOutputObjective( weights=weights, outcomes=outcome_indices.tolist() ) - objective_thresholds = torch.mul(objective_thresholds, weights) - return objective, objective_thresholds def pareto_frontier_evaluator( @@ -128,31 +118,14 @@ def pareto_frontier_evaluator( "points on the pareto frontier." ) - # Apply objective_weights to outcomes and objective_thresholds. - # If objective_thresholds is not None use a dummy tensor of zeros. - ( - obj, - weighted_objective_thresholds, - ) = get_weighted_mc_objective_and_objective_thresholds( - objective_weights=objective_weights, - objective_thresholds=( - objective_thresholds - if objective_thresholds is not None - else torch.zeros( - objective_weights.shape[1], - dtype=objective_weights.dtype, - device=objective_weights.device, - ) - ), - ) + # Apply objective_weights to outcomes. + obj = get_weighted_mc_objective(objective_weights=objective_weights) Y_obj = obj(Y) indx_frontier = torch.arange(Y.shape[0], dtype=torch.long, device=Y.device) # Filter Y, Yvar, Y_obj to items that dominate all objective thresholds if objective_thresholds is not None: - objective_thresholds_mask = torch.all( - Y_obj >= weighted_objective_thresholds, dim=1 - ) + objective_thresholds_mask = torch.all(Y_obj >= objective_thresholds, dim=1) Y = Y[objective_thresholds_mask] Yvar = Yvar[objective_thresholds_mask] Y_obj = Y_obj[objective_thresholds_mask] @@ -189,7 +162,7 @@ def pareto_frontier_evaluator( def infer_objective_thresholds( model: Model, - objective_weights: Tensor, # objective_directions + objective_weights: Tensor, X_observed: Tensor, outcome_constraints: tuple[Tensor, Tensor] | None = None, subset_idcs: Tensor | None = None, @@ -211,25 +184,23 @@ def infer_objective_thresholds( model: A fitted botorch Model. objective_weights: A ``(n_objectives, n_outcomes)`` tensor of objective weights. These should not be subsetted. - X_observed: A `n x d`-dim tensor of in-sample points to use for + X_observed: A ``n x d``-dim tensor of in-sample points to use for determining the current in-sample Pareto frontier. outcome_constraints: A tuple of (A, b). For k outcome constraints and m outputs at f(x), A is (k x m) and b is (k x 1) such that A f(x) <= b. These should not be subsetted. subset_idcs: The indices of the outcomes that are modeled by the - provided model. If subset_idcs not None, this method infers + provided model. If subset_idcs is not None, this method infers whether the model is subsetted. - objective_thresholds: Any known objective thresholds to pass to - `infer_reference_point` heuristic. This should not be subsetted. - If only a subset of the objectives have known thresholds, the - remaining objectives should be NaN. If no objective threshold - was provided, this can be `None`. + objective_thresholds: A ``(n_objectives,)`` tensor of maximization- + aligned objective thresholds. NaN entries indicate thresholds to + infer. If no objective thresholds are provided, this can be + ``None``. Returns: - A `m`-dim tensor of objective thresholds, where the objective - threshold is `nan` if the outcome is not an objective. + A ``(n_objectives,)`` tensor of maximization-aligned objective + thresholds. """ - num_outcomes = objective_weights.shape[1] if subset_idcs is None: # Subset the model so that we only compute the posterior # over the relevant outcomes. @@ -264,30 +235,16 @@ def infer_objective_thresholds( raise AxError(NO_FEASIBLE_POINTS_MESSAGE) obj_indices, obj_weights_subset = extract_objectives(objective_weights) obj_mask = torch.tensor(obj_indices, device=objective_weights.device) + # Convert predictions to maximization-aligned objective values. obj = pred[..., obj_mask] * obj_weights_subset pareto_obj = obj[is_non_dominated(obj)] - # If objective thresholds are provided, set max_ref_point accordingly. - if objective_thresholds is not None: - max_ref_point = objective_thresholds[obj_mask] * obj_weights_subset - else: - max_ref_point = None - objective_thresholds = infer_reference_point( + # Input thresholds are already (n_objectives,) and maximization-aligned. + max_ref_point = objective_thresholds + return infer_reference_point( pareto_Y=pareto_obj, max_ref_point=max_ref_point, scale=0.1, ) - # multiply by objective weights to return objective thresholds in the - # unweighted space - objective_thresholds = objective_thresholds * obj_weights_subset - full_objective_thresholds = torch.full( - (num_outcomes,), - float("nan"), - dtype=objective_weights.dtype, - device=objective_weights.device, - ) - obj_idcs = subset_idcs[obj_mask] - full_objective_thresholds[obj_idcs] = objective_thresholds.clone() - return full_objective_thresholds def _check_posterior_type( diff --git a/ax/generators/torch/tests/test_acquisition.py b/ax/generators/torch/tests/test_acquisition.py index 7e241cce090..2381a6dde15 100644 --- a/ax/generators/torch/tests/test_acquisition.py +++ b/ax/generators/torch/tests/test_acquisition.py @@ -75,6 +75,7 @@ from botorch.utils.constraints import get_outcome_constraint_transforms from botorch.utils.datasets import SupervisedDataset from botorch.utils.testing import MockPosterior, skip_if_import_error +from pyre_extensions import none_throws from torch import Tensor @@ -272,7 +273,6 @@ def test_init( model=acquisition.surrogate.model, objective_weights=self.objective_weights, outcome_constraints=self.outcome_constraints, - objective_thresholds=self.objective_thresholds, ) # Mock so that we can check that arguments are passed correctly. @@ -280,8 +280,8 @@ def test_init( @mock.patch( f"{ACQUISITION_PATH}.subset_model", # pyre-fixme[6]: For 1st param expected `Model` but got `None`. - # pyre-fixme[6]: For 5th param expected `Tensor` but got `None`. - return_value=SubsetModelData(None, torch.ones(1), None, None, None), + # pyre-fixme[6]: For 4th param expected `Tensor` but got `None`. + return_value=SubsetModelData(None, torch.ones(1), None, None), ) @mock.patch( f"{ACQUISITION_PATH}.get_botorch_objective_and_transform", @@ -1207,8 +1207,10 @@ def test_init_moo( moo_objective_weights = torch.tensor( [[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0]], **self.tkwargs ) + # (n_objectives,) maximization-aligned thresholds. Both objectives + # minimize (w=-1), so raw thresholds 0.5 and 1.5 become -0.5 and -1.5. moo_objective_thresholds = ( - torch.tensor([0.5, 1.5, float("nan")], **self.tkwargs) + torch.tensor([-0.5, -1.5], **self.tkwargs) if with_objective_thresholds else None ) @@ -1244,14 +1246,14 @@ def test_init_moo( botorch_acqf_options=self.botorch_acqf_options, ) if moo_objective_thresholds is not None: + obj_thresholds = acquisition.objective_thresholds + self.assertIsNotNone(obj_thresholds) self.assertTrue( torch.equal( - moo_objective_thresholds[:2], - # pyre-fixme[16]: Optional type has no attribute `__getitem__`. - acquisition.objective_thresholds[:2], + moo_objective_thresholds, + none_throws(obj_thresholds), ) ) - self.assertTrue(np.isnan(acquisition.objective_thresholds[2].item())) # test inferred objective_thresholds with ExitStack() as es: preds = torch.tensor( @@ -1285,14 +1287,16 @@ def test_init_moo( if with_no_X_observed: self.assertIsNone(acquisition.objective_thresholds) else: + # Inferred thresholds are (n_objectives,) maximization-aligned. + inferred = none_throws(acquisition.objective_thresholds) self.assertTrue( torch.equal( - acquisition.objective_thresholds[:2], - torch.tensor([9.9, 3.3], **self.tkwargs), + inferred, + torch.tensor([-9.9, -3.3], **self.tkwargs), ) ) - self.assertTrue(np.isnan(acquisition.objective_thresholds[2].item())) - # With partial thresholds. + # With partial thresholds (n_objectives=2). + # Obj 1 (minimize) has known threshold -5.5 (maximization-aligned). acquisition = Acquisition( surrogate=self.surrogate, search_space_digest=self.search_space_digest, @@ -1300,7 +1304,7 @@ def test_init_moo( torch_opt_config=dataclasses.replace( torch_opt_config, objective_thresholds=torch.tensor( - [float("nan"), 5.5, float("nan")], **self.tkwargs + [float("nan"), -5.5], **self.tkwargs ), ), options=self.options, @@ -1308,17 +1312,17 @@ def test_init_moo( ) if with_no_X_observed: # Thresholds are not updated. - self.assertEqual(acquisition.objective_thresholds[1].item(), 5.5) - self.assertTrue(np.isnan(acquisition.objective_thresholds[0].item())) - self.assertTrue(np.isnan(acquisition.objective_thresholds[2].item())) + partial = none_throws(acquisition.objective_thresholds) + self.assertEqual(partial[1].item(), -5.5) + self.assertTrue(np.isnan(partial[0].item())) else: + partial = none_throws(acquisition.objective_thresholds) self.assertTrue( torch.equal( - acquisition.objective_thresholds[:2], - torch.tensor([9.9, 5.5], **self.tkwargs), + partial, + torch.tensor([-9.9, -5.5], **self.tkwargs), ) ) - self.assertTrue(np.isnan(acquisition.objective_thresholds[2].item())) def test_init_no_X_observed(self) -> None: self.test_init_moo(with_no_X_observed=True, with_outcome_constraints=False) @@ -1364,7 +1368,7 @@ def test_init_p_feasible(self) -> None: any("Failed to infer objective thresholds." in str(log) for log in logs) ) self.assertIsInstance(acquisition.acqf, qLogProbabilityOfFeasibility) - self.assertIsNone(acquisition._full_objective_thresholds) + self.assertIsNone(acquisition._objective_thresholds) @mock_botorch_optimize def test_p_feasible_moo(self) -> None: @@ -1406,7 +1410,7 @@ def test_p_feasible_moo(self) -> None: any("Failed to infer objective thresholds." in str(log) for log in logs) ) self.assertIsInstance(acquisition.acqf, qLogProbabilityOfFeasibility) - self.assertIsNone(acquisition._full_objective_thresholds) + self.assertIsNone(acquisition._objective_thresholds) # Verify only outcome constraints are used (no threshold-derived ones). oc_transforms = get_outcome_constraint_transforms( outcome_constraints=outcome_constraints @@ -1437,9 +1441,8 @@ def test_p_feasible_moo_with_thresholds(self) -> None: moo_objective_weights = torch.tensor( [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], **self.tkwargs ) - moo_objective_thresholds = torch.tensor( - [0.5, 1.5, float("nan")], **self.tkwargs - ) + # (n_objectives,) maximization-aligned thresholds. Both maximize. + moo_objective_thresholds = torch.tensor([0.5, 1.5], **self.tkwargs) outcome_constraints = ( torch.tensor([[0.0, 0.0, 1.0]], **self.tkwargs), torch.tensor([[0.5]], **self.tkwargs), @@ -2280,8 +2283,8 @@ def test_select_from_candidate_set(self) -> None: @mock.patch( f"{ACQUISITION_PATH}.subset_model", # pyre-fixme[6]: For 1st param expected `Model` but got `None`. - # pyre-fixme[6]: For 5th param expected `Tensor` but got `None`. - return_value=SubsetModelData(None, torch.ones(1), None, None, None), + # pyre-fixme[6]: For 4th param expected `Tensor` but got `None`. + return_value=SubsetModelData(None, torch.ones(1), None, None), ) @mock.patch( f"{ACQUISITION_PATH}.get_botorch_objective_and_transform", diff --git a/ax/generators/torch/tests/test_generator.py b/ax/generators/torch/tests/test_generator.py index 82a71d6faa3..ba338f9819e 100644 --- a/ax/generators/torch/tests/test_generator.py +++ b/ax/generators/torch/tests/test_generator.py @@ -152,9 +152,7 @@ def setUp(self) -> None: self.moo_objective_weights = torch.tensor( [[1.0, 0.0, 0.0], [0.0, 1.5, 0.0]], **tkwargs ) - self.moo_objective_thresholds = torch.tensor( - [0.5, 1.5, float("nan")], **tkwargs - ) + self.moo_objective_thresholds = torch.tensor([0.5, 1.5], **tkwargs) self.moo_outcome_constraints = ( torch.tensor([[1.0, 0.0, 0.0]], **tkwargs), torch.tensor([[3.5]], **tkwargs), @@ -1079,7 +1077,7 @@ def test_MOO(self) -> None: "training_data", "X_baseline", "model", - "objective_thresholds", + "ref_point", "eta", "constraints_tuple", } @@ -1108,16 +1106,18 @@ def test_MOO(self) -> None: torch.cat([ds.Yvar for ds in self.moo_training_data], dim=-1), ) ) + # BoTorch receives maximization-aligned thresholds directly as ref_point. self.assertTrue( torch.equal( - ckwargs["objective_thresholds"], self.moo_objective_thresholds[:2] + ckwargs["ref_point"], + self.moo_objective_thresholds, ) ) self.assertIs(ckwargs["constraints"], constraints) + # gen_metadata stores maximization-aligned thresholds. obj_t = gen_results.gen_metadata["objective_thresholds"] - self.assertTrue(torch.equal(obj_t[:2], self.moo_objective_thresholds[:2])) - self.assertTrue(np.isnan(obj_t[2].item())) + self.assertTrue(torch.equal(obj_t, self.moo_objective_thresholds)) self.assertIsInstance(ckwargs["objective"], WeightedMCMultiOutputObjective) self.assertTrue( @@ -1156,7 +1156,7 @@ def test_MOO(self) -> None: linear_constraints=linear_constraints, ) - objective_thresholds = torch.tensor([9.9, 3.3, float("nan")]) + objective_thresholds = torch.tensor([9.9, 3.3]) with mock.patch( "ax.generators.torch.botorch_modular.acquisition" ".infer_objective_thresholds", @@ -1191,8 +1191,7 @@ def test_MOO(self) -> None: self.assertEqual(m.num_outputs, 2) self.assertIn("objective_thresholds", gen_results.gen_metadata) obj_t = gen_results.gen_metadata["objective_thresholds"] - self.assertTrue(torch.equal(obj_t[:2], objective_thresholds[:2])) - self.assertTrue(np.isnan(obj_t[2].item())) + self.assertTrue(torch.equal(obj_t, objective_thresholds)) # Avoid polluting the registry for other tests; re-register correct input # constructor for qLogNEHVI. diff --git a/ax/generators/torch/tests/test_utils.py b/ax/generators/torch/tests/test_utils.py index 10bcc6d1332..97932ea9aed 100644 --- a/ax/generators/torch/tests/test_utils.py +++ b/ax/generators/torch/tests/test_utils.py @@ -391,20 +391,23 @@ def test_choose_botorch_acqf_class(self) -> None: ) def test_objective_threshold_to_outcome_constraints(self) -> None: - # Test basic conversion: maximize obj 0, minimize obj 1, skip obj 2. + # Test basic conversion: maximize obj 0, minimize obj 1. + # Thresholds are (n_objectives,) and maximization-aligned. objective_weights = torch.tensor([[1.0, 0.0, 0.0], [0.0, -1.0, 0.0]]) - objective_thresholds = torch.tensor([0.5, 1.5, float("nan")]) + # Obj 0 (maximize): threshold 0.5. Obj 1 (minimize): threshold -1.5. + objective_thresholds = torch.tensor([0.5, -1.5]) A, b = _objective_threshold_to_outcome_constraints( objective_weights=objective_weights, objective_thresholds=objective_thresholds, ) - # Two objectives (idx 0 and 1) have nonzero weights and non-NaN thresholds. + # Both objectives have non-NaN thresholds. self.assertEqual(A.shape, (2, 3)) self.assertEqual(b.shape, (2, 1)) - # For idx 0: w=1.0, t=0.5 → A[0, 0]=-1.0, b[0]=-0.5 + # A = -objective_weights, b = -objective_thresholds + # For obj 0: A[0]=-[1, 0, 0], b[0]=-0.5 self.assertEqual(A[0, 0].item(), -1.0) self.assertEqual(b[0].item(), -0.5) - # For idx 1: w=-1.0, t=1.5 → A[1, 1]=1.0, b[1]=1.5 + # For obj 1: A[1]=-[0, -1, 0]=[0, 1, 0], b[1]=1.5 self.assertEqual(A[1, 1].item(), 1.0) self.assertEqual(b[1].item(), 1.5) @@ -478,16 +481,17 @@ def test_choose_botorch_acqf_class_moo_objective_thresholds(self) -> None: ), ) - # With minimization: w=-1, threshold=1.5 means Y should be <= 1.5. - # Point 0: obj1=1.0 (good, <=2.0 after flip), obj2=2.0 > 1.5 → fails. - # Point 1: obj1=3.0 > 2.0 → fails. + # With minimization: thresholds are maximization-aligned (negated). + # weighted_Y = Y * [-1, -1]. Thresholds = [-2.0, -1.5]. + # Point 0: weighted_Y=[-1, -2], [-1>=-2 ✓, -2>=-1.5 ✗] → fails. + # Point 1: weighted_Y=[-3, -0.5], [-3>=-2 ✗] → fails. self.assertEqual( qLogProbabilityOfFeasibility, choose_botorch_acqf_class( search_space_digest=ssd, torch_opt_config=TorchOptConfig( objective_weights=torch.tensor([[-1.0, 0.0], [0.0, -1.0]]), - objective_thresholds=torch.tensor([2.0, 1.5]), + objective_thresholds=torch.tensor([-2.0, -1.5]), ), datasets=[dataset], ), diff --git a/ax/generators/torch/utils.py b/ax/generators/torch/utils.py index 54f30a785a7..3b7f5fcb7d0 100644 --- a/ax/generators/torch/utils.py +++ b/ax/generators/torch/utils.py @@ -87,7 +87,6 @@ class SubsetModelData: model: Model objective_weights: Tensor outcome_constraints: tuple[Tensor, Tensor] | None - objective_thresholds: Tensor | None indices: Tensor @@ -217,7 +216,6 @@ def subset_model( model: Model, objective_weights: Tensor, outcome_constraints: tuple[Tensor, Tensor] | None = None, - objective_thresholds: Tensor | None = None, ) -> SubsetModelData: """Subset a botorch model to the outputs used in the optimization. @@ -227,17 +225,15 @@ def subset_model( input arguments. objective_weights: A ``(n_objectives, n_outcomes)`` tensor of objective weights. - objective_thresholds: A ``m``-dim tensor of objective thresholds. There - is one for each modeled metric. outcome_constraints: A tuple of (A, b). For k outcome constraints and m outputs at f(x), A is (k x m) and b is (k x 1) such that A f(x) <= b. (Not used by single task models) Returns: A SubsetModelData dataclass containing the model, objective_weights, - outcome_constraints, objective thresholds, all subset to only those - outputs that appear in either the objective weights or the outcome - constraints, along with the indices of the outputs. + outcome_constraints, all subset to only those outputs that appear in + either the objective weights or the outcome constraints, along with + the indices of the outputs. """ nonzero = (objective_weights != 0).any(dim=0) if outcome_constraints is not None: @@ -255,7 +251,6 @@ def subset_model( model=model, objective_weights=objective_weights, outcome_constraints=outcome_constraints, - objective_thresholds=objective_thresholds, indices=torch.arange( num_outcomes, device=objective_weights.device, @@ -272,8 +267,6 @@ def subset_model( if outcome_constraints is not None: A, b = outcome_constraints outcome_constraints = A[:, nonzero], b - if objective_thresholds is not None: - objective_thresholds = objective_thresholds[nonzero] except NotImplementedError: idcs_t = torch.arange( model.num_outputs, @@ -283,7 +276,6 @@ def subset_model( model=model, objective_weights=objective_weights, outcome_constraints=outcome_constraints, - objective_thresholds=objective_thresholds, indices=idcs_t, ) diff --git a/ax/generators/torch_base.py b/ax/generators/torch_base.py index b5219c07531..1a4b8731e4e 100644 --- a/ax/generators/torch_base.py +++ b/ax/generators/torch_base.py @@ -41,10 +41,12 @@ class TorchOptConfig: outcome_constraints: A tuple of (A, b). For k outcome constraints and m outputs at f(x), A is (k x m) and b is (k x 1) such that A f(x) <= b. - objective_thresholds: A tensor containing thresholds forming a - reference point from which to calculate pareto frontier hypervolume. - Points that do not dominate the objective_thresholds contribute - nothing to hypervolume. + objective_thresholds: A ``(n_objectives,)`` tensor of maximization-aligned + objective thresholds forming a reference point from which to calculate + Pareto frontier hypervolume. Points that do not dominate the + objective_thresholds contribute nothing to hypervolume. NaN entries + indicate thresholds that need to be inferred. ``None`` for single- + objective optimization. linear_constraints: A tuple of (A, b). For k linear constraints on d-dimensional x, A is (k x d) and b is (k x 1) such that A x <= b for feasible x.