Skip to content

Commit

Permalink
Change MultiObjective data model
Browse files Browse the repository at this point in the history
Summary:
tldr; the main change here is from

```
MultiObjective:
* Metrics
* Minimize
```

to
```
MultiObjective:
* Objectives
```

Note: Since this is a long diff, I'll flag the lines that are significant!

## CURRENT SITUATION
An Objective in Ax is composed of:
* Metric: which is composed of a name and a lower_is_better field, the latter of which indicates whether lower values are preferable to higher values
* Minimize: a boolean flag indicating whether our optimization should seek to minimize or maximize this metric

Note that lower_is_better and minimize are distinct fields with different semantics. It is possible to maximize a metric for which lower_is_better=True! We warn you against this, but there are some legitimate use cases for this (though admittedly I can't remember what, but I swear someone told me there were...).

A MultiObjective in Ax is composed of:
* Metrics: a list of metric objects
* Minimize: a boolean flag indicating whether our optimization should seek to minimize or maximize the overall MultiObjective

At first glance, this seems like a natural extension of the single objective case. But there is a key difference. What if you have a MultiObjective composed of metrics A and B, and you want our optimization to minimize metric A but maximize metric B? We only have the one minimize field. It actually doesn't make sense to minimize an entire MultiObjective; you want to control each component objective separately.

The way this is currently implemented is that we override the lower_is_better attribute of each Metric in the list. Rather than having this attribute indicate whether lower or higher values of the metric are better, as in the single objective case, we use this attribute to indicate whether the optimization should maximize or minimize this metric. The minimize attribute, if set, is then used to flip the direction of these lower_is_better fields.

This is confusing! While it's not causing any major issues right now, it came up during Jakepodell UI work, where we pass both lower_is_better and minimize down from the UI to Python for each objective in the MultiObjective, and currently there's no way to represent both of these values in the Python codebase.

## PROPOSAL
A MultiObjective should be composed of:
* Objectives: a list of objective objects, each with their own minimize field

... and that's it. No minimize field on MultiObjective.

Reviewed By: Balandat

Differential Revision: D28869199

fbshipit-source-id: daa5d40b0754a6beaba9d85d06e27fc1b05b2220
  • Loading branch information
ldworkin authored and facebook-github-bot committed Jun 9, 2021
1 parent 00a5436 commit 595095e
Show file tree
Hide file tree
Showing 15 changed files with 215 additions and 116 deletions.
106 changes: 75 additions & 31 deletions ax/core/objective.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@
from typing import Any, Iterable, List, Optional, Tuple

from ax.core.metric import Metric
from ax.utils.common.base import Base
from ax.utils.common.base import SortableBase
from ax.utils.common.logger import get_logger
from ax.utils.common.typeutils import not_none


logger = get_logger(__name__)


class Objective(Base):
class Objective(SortableBase):
"""Base class for representing an objective.
Attributes:
Expand Down Expand Up @@ -85,47 +85,62 @@ def get_unconstrainable_metrics(self) -> List[Metric]:
"""Return a list of metrics that are incompatible with OutcomeConstraints."""
return self.metrics

@property
def _unique_id(self) -> str:
return str(self)


class MultiObjective(Objective):
"""Class for an objective composed of a multiple component objectives.
The Acquisition function determines how the objectives are weighted.
Attributes:
metrics: List of metrics.
objectives: List of objectives.
"""

weights: List[float]

def __init__(
self,
metrics: List[Metric],
minimize: bool = False,
objectives: Optional[List[Objective]] = None,
**extra_kwargs: Any, # Here to satisfy serialization.
) -> None:
"""Create a new objective.
Args:
metrics: The list of metrics to be jointly optimized.
minimize: If true, minimize the aggregate of these metrics.
objectives: The list of objectives to be jointly optimized.
"""
self._metrics = metrics
self.weights = []
for metric in metrics:
# Set weights from "lower_is_better"
if metric.lower_is_better is None:
logger.warning(
f"metric {metric.name} has not set `lower_is_better`. "
"Treating as `False` (Metric should be maximized)."
# Support backwards compatibility for old API in which
# MultiObjective constructor accepted `metrics` and `minimize`
# rather than `objectives`
if objectives is None:
if "metrics" not in extra_kwargs:
raise ValueError(
"Must either specify `objectives` or `metrics` "
"as input to `MultiObjective` constructor."
)
self.weights.append(-1.0 if metric.lower_is_better is True else 1.0)
self.minimize = minimize

@property
def metric_weights(self) -> Iterable[Tuple[Metric, float]]:
"""Get the objective metrics and weights."""
return zip(self.metrics, self.weights)
metrics = extra_kwargs["metrics"]
minimize = extra_kwargs.get("minimize", False)
warnings.warn(
"Passing `metrics` and `minimize` as input to the `MultiObjective` "
"constructor will soon be deprecated. Instead, pass a list of "
"`objectives`. This will become an error in the future.",
DeprecationWarning,
)
objectives = []
for metric in metrics:
lower_is_better = metric.lower_is_better or False
_minimize = not lower_is_better if minimize else lower_is_better
objectives.append(Objective(metric=metric, minimize=_minimize))

self._objectives = not_none(objectives)

# For now, assume all objectives are weighted equally.
# This might be used in the future to change emphasis on the
# relative focus of the exploration during the optimization.
self.weights = [1.0 for _ in range(len(objectives))]

@property
def metric(self) -> Metric:
Expand All @@ -137,25 +152,31 @@ def metric(self) -> Metric:
@property
def metrics(self) -> List[Metric]:
"""Get the objective metrics."""
return self._metrics
return [o.metric for o in self._objectives]

@property
def objectives(self) -> List[Objective]:
"""Get the objectives."""
return self._objectives

@property
def objective_weights(self) -> Iterable[Tuple[Objective, float]]:
"""Get the objectives and weights."""
return zip(self.objectives, self.weights)

def clone(self) -> Objective:
"""Create a copy of the objective."""
return MultiObjective(
metrics=[m.clone() for m in self.metrics], minimize=self.minimize
)
return MultiObjective(objectives=[o.clone() for o in self.objectives])

def __repr__(self) -> str:
return "MultiObjective(metric_names={}, minimize={})".format(
[metric.name for metric in self.metrics], self.minimize
)
return f"MultiObjective(objectives={self.objectives})"

def get_unconstrainable_metrics(self) -> List[Metric]:
"""Return a list of metrics that are incompatible with OutcomeConstraints."""
return []


class ScalarizedObjective(MultiObjective):
class ScalarizedObjective(Objective):
"""Class for an objective composed of a linear scalarization of metrics.
Attributes:
Expand Down Expand Up @@ -184,8 +205,27 @@ def __init__(
else:
if len(weights) != len(metrics):
raise ValueError("Length of weights must equal length of metrics")
super().__init__(metrics, minimize)

self._metrics = metrics
self.weights = weights
self.minimize = minimize

@property
def metric(self) -> Metric:
"""Override base method to error."""
raise NotImplementedError(
f"{type(self).__name__} is composed of multiple metrics"
)

@property
def metrics(self) -> List[Metric]:
"""Get the metrics."""
return self._metrics

@property
def metric_weights(self) -> Iterable[Tuple[Metric, float]]:
"""Get the metrics and weights."""
return zip(self.metrics, self.weights)

def clone(self) -> Objective:
"""Create a copy of the objective."""
Expand All @@ -199,3 +239,7 @@ def __repr__(self) -> str:
return "ScalarizedObjective(metric_names={}, weights={}, minimize={})".format(
[metric.name for metric in self.metrics], self.weights, self.minimize
)

def get_unconstrainable_metrics(self) -> List[Metric]:
"""Return a list of metrics that are incompatible with OutcomeConstraints."""
return []
9 changes: 5 additions & 4 deletions ax/core/optimization_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import Dict, List, Optional

from ax.core.metric import Metric
from ax.core.objective import MultiObjective, Objective
from ax.core.objective import MultiObjective, Objective, ScalarizedObjective
from ax.core.outcome_constraint import (
ComparisonOp,
ObjectiveThreshold,
Expand Down Expand Up @@ -352,12 +352,13 @@ def _validate_optimization_config(
Args:
outcome_constraints: Constraints to validate.
"""
if not isinstance(objective, MultiObjective):
if not isinstance(objective, (MultiObjective, ScalarizedObjective)):
raise TypeError(
(
"`MultiObjectiveOptimizationConfig` requires an objective "
"of type `MultiObjective`. Use `OptimizationConfig` instead "
"if using a single-metric objective."
"of type `MultiObjective` or `ScalarizedObjective`. "
"Use `OptimizationConfig` instead if using a "
"single-metric objective."
)
)
outcome_constraints = outcome_constraints or []
Expand Down
46 changes: 42 additions & 4 deletions ax/core/tests/test_objective.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
from ax.core.objective import MultiObjective, Objective, ScalarizedObjective
from ax.utils.common.testutils import TestCase

MULTI_OBJECTIVE_REPR = """
MultiObjective(objectives=
[Objective(metric_name="m1", minimize=False),
Objective(metric_name="m2", minimize=True),
Objective(metric_name="m3", minimize=False)])
"""


class ObjectiveTest(TestCase):
def setUp(self):
Expand All @@ -18,9 +25,18 @@ def setUp(self):
"m2": Metric(name="m2", lower_is_better=True),
"m3": Metric(name="m3", lower_is_better=False),
}
self.objectives = {
"o1": Objective(metric=self.metrics["m1"]),
"o2": Objective(metric=self.metrics["m2"], minimize=True),
"o3": Objective(metric=self.metrics["m3"], minimize=False),
}
self.objective = Objective(metric=self.metrics["m1"], minimize=False)
self.multi_objective = MultiObjective(
metrics=[self.metrics["m1"], self.metrics["m2"], self.metrics["m3"]]
objectives=[
self.objectives["o1"],
self.objectives["o2"],
self.objectives["o3"],
]
)
self.scalarized_objective = ScalarizedObjective(
metrics=[self.metrics["m1"], self.metrics["m2"]]
Expand Down Expand Up @@ -54,15 +70,37 @@ def testMultiObjective(self):
return self.multi_objective.metric

self.assertEqual(self.multi_objective.metrics, list(self.metrics.values()))
weights = [mw[1] for mw in self.multi_objective.metric_weights]
self.assertEqual(weights, [1.0, -1.0, 1.0])
minimizes = [obj.minimize for obj in self.multi_objective.objectives]
self.assertEqual(minimizes, [False, True, False])
weights = [mw[1] for mw in self.multi_objective.objective_weights]
self.assertEqual(weights, [1.0, 1.0, 1.0])
self.assertEqual(self.multi_objective.clone(), self.multi_objective)
self.assertEqual(
str(self.multi_objective),
"MultiObjective(metric_names=['m1', 'm2', 'm3'], minimize=False)",
(
"MultiObjective(objectives="
'[Objective(metric_name="m1", minimize=False), '
'Objective(metric_name="m2", minimize=True), '
'Objective(metric_name="m3", minimize=False)])'
),
)
self.assertEqual(self.multi_objective.get_unconstrainable_metrics(), [])

def testMultiObjectiveBackwardsCompatibility(self):
multi_objective = MultiObjective(
metrics=[self.metrics["m1"], self.metrics["m2"], self.metrics["m3"]]
)
minimizes = [obj.minimize for obj in multi_objective.objectives]
self.assertEqual(multi_objective.metrics, list(self.metrics.values()))
self.assertEqual(minimizes, [False, True, False])

multi_objective_min = MultiObjective(
metrics=[self.metrics["m1"], self.metrics["m2"], self.metrics["m3"]],
minimize=True,
)
minimizes = [obj.minimize for obj in multi_objective_min.objectives]
self.assertEqual(minimizes, [True, False, True])

def testScalarizedObjective(self):
with self.assertRaises(NotImplementedError):
return self.scalarized_objective.metric
Expand Down
18 changes: 13 additions & 5 deletions ax/core/tests/test_optimization_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@
)

MOOC_STR = (
"OptimizationConfig(objective=MultiObjective("
"metric_names=['m1', 'm2'], minimize=False), "
"OptimizationConfig(objective=MultiObjective(objectives="
'[Objective(metric_name="m1", minimize=True), '
'Objective(metric_name="m2", minimize=True)]), '
"outcome_constraints=[OutcomeConstraint(m2 >= -0.25%), "
"OutcomeConstraint(m2 <= 0.25%)], objective_thresholds=[])"
)
Expand All @@ -41,7 +42,7 @@ def setUp(self):
self.objective = Objective(metric=self.metrics["m1"], minimize=False)
self.alt_objective = Objective(metric=self.metrics["m2"], minimize=False)
self.multi_objective = MultiObjective(
metrics=[self.metrics["m1"], self.metrics["m2"]]
objectives=[self.objective, self.alt_objective],
)
self.m2_objective = ScalarizedObjective(
metrics=[self.metrics["m1"], self.metrics["m2"]]
Expand Down Expand Up @@ -186,11 +187,18 @@ def setUp(self):
"m2": Metric(name="m2", lower_is_better=False),
"m3": Metric(name="m3", lower_is_better=False),
}
self.objectives = {
"o1": Objective(metric=self.metrics["m1"]),
"o2": Objective(metric=self.metrics["m2"], minimize=True),
"o3": Objective(metric=self.metrics["m3"], minimize=False),
}
self.objective = Objective(metric=self.metrics["m1"], minimize=False)
self.multi_objective = MultiObjective(
metrics=[self.metrics["m1"], self.metrics["m2"]]
objectives=[self.objectives["o1"], self.objectives["o2"]]
)
self.multi_objective_just_m2 = MultiObjective(
objectives=[self.objectives["o2"]]
)
self.multi_objective_just_m2 = MultiObjective(metrics=[self.metrics["m2"]])
self.outcome_constraint = OutcomeConstraint(
metric=self.metrics["m2"], op=ComparisonOp.GEQ, bound=-0.25
)
Expand Down
9 changes: 5 additions & 4 deletions ax/modelbridge/modelbridge_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,16 +206,17 @@ def extract_objective_weights(objective: Objective, outcomes: List[str]) -> np.n
n-length list of weights.
"""
s = -1.0 if objective.minimize else 1.0
objective_weights = np.zeros(len(outcomes))
if isinstance(objective, ScalarizedObjective):
s = -1.0 if objective.minimize else 1.0
for obj_metric, obj_weight in objective.metric_weights:
objective_weights[outcomes.index(obj_metric.name)] = obj_weight * s
elif isinstance(objective, MultiObjective):
for obj_metric, obj_weight in objective.metric_weights:
# Rely on previously extracted lower_is_better weights not objective.
objective_weights[outcomes.index(obj_metric.name)] = obj_weight or s
for obj, obj_weight in objective.objective_weights:
s = -1.0 if obj.minimize else 1.0
objective_weights[outcomes.index(obj.metric.name)] = obj_weight * s
else:
s = -1.0 if objective.minimize else 1.0
objective_weights[outcomes.index(objective.metric.name)] = s
return objective_weights

Expand Down
8 changes: 6 additions & 2 deletions ax/modelbridge/tests/test_numpy_modelbridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,12 @@ def testGen(self, mock_init, mock_best_point, mock_gen):
# Test with MultiObjective (unweighted multiple objectives)
oc3 = MultiObjectiveOptimizationConfig(
objective=MultiObjective(
metrics=[Metric(name="a"), Metric(name="b", lower_is_better=True)],
minimize=True,
objectives=[
Objective(metric=Metric(name="a")),
Objective(
metric=Metric(name="b", lower_is_better=True), minimize=True
),
],
)
)
search_space = SearchSpace(self.parameters) # Unconstrained
Expand Down
4 changes: 3 additions & 1 deletion ax/modelbridge/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,9 @@ def test_extract_outcome_constraints(self):

def test_extract_objective_thresholds(self):
outcomes = ["m1", "m2", "m3", "m4"]
objective = MultiObjective(metrics=[Metric(name) for name in outcomes[:3]])
objective = MultiObjective(
objectives=[Objective(metric=Metric(name)) for name in outcomes[:3]]
)
objective_thresholds = [
ObjectiveThreshold(
metric=Metric(name), op=ComparisonOp.LEQ, bound=float(i + 2)
Expand Down
Loading

0 comments on commit 595095e

Please sign in to comment.