Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support constrained optimization in best_trial #5426

Merged
27 changes: 27 additions & 0 deletions optuna/study/_constrained_optimization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

from collections.abc import Sequence

from optuna.trial import FrozenTrial


_CONSTRAINTS_KEY = "constraints"


def _get_feasible_trials(trials: Sequence[FrozenTrial]) -> list[FrozenTrial]:
"""Return feasible trials from given trials.

This function assumes that the trials were created in constrained optimization.
Therefore, if there is no violation value in the trial, it is considered infeasible.


Returns:
A list of feasible trials.
"""

feasible_trials = []
for trial in trials:
constraints = trial.system_attrs.get(_CONSTRAINTS_KEY)
if constraints is not None and all(x <= 0.0 for x in constraints):
feasible_trials.append(trial)
return feasible_trials
13 changes: 1 addition & 12 deletions optuna/study/_multi_objective.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,12 @@
import numpy as np

import optuna
from optuna.study._constrained_optimization import _get_feasible_trials
from optuna.study._study_direction import StudyDirection
from optuna.trial import FrozenTrial
from optuna.trial import TrialState


_CONSTRAINTS_KEY = "constraints"


def _get_feasible_trials(trials: Sequence[FrozenTrial]) -> list[FrozenTrial]:
feasible_trials = []
for trial in trials:
constraints = trial.system_attrs.get(_CONSTRAINTS_KEY)
if constraints is None or all([x <= 0.0 for x in constraints]):
feasible_trials.append(trial)
return feasible_trials


def _get_pareto_front_trials_by_trials(
trials: Sequence[FrozenTrial],
directions: Sequence[StudyDirection],
Expand Down
26 changes: 24 additions & 2 deletions optuna/study/study.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
from optuna.distributions import _convert_old_distribution_to_new_distribution
from optuna.distributions import BaseDistribution
from optuna.storages._heartbeat import is_heartbeat_enabled
from optuna.study._constrained_optimization import _CONSTRAINTS_KEY
from optuna.study._constrained_optimization import _get_feasible_trials
from optuna.study._multi_objective import _get_pareto_front_trials
from optuna.study._optimize import _optimize
from optuna.study._study_direction import StudyDirection
Expand Down Expand Up @@ -157,7 +159,23 @@ def best_trial(self) -> FrozenTrial:
"using Study.best_trials to retrieve a list containing the best trials."
)

return copy.deepcopy(self._storage.get_best_trial(self._study_id))
best_trial = self._storage.get_best_trial(self._study_id)

# If the trial with the best value is infeasible, select the best trial from all feasible
# trials. Note that the behavior is undefined when constrained optimization without the
# violation value in the best-valued trial.
constraints = best_trial.system_attrs.get(_CONSTRAINTS_KEY)
if constraints is not None and any([x > 0.0 for x in constraints]):
complete_trials = self.get_trials(deepcopy=False, states=[TrialState.COMPLETE])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to use cache here or would it be buggy because some trials might finish between the last query and this query of self.get_trials?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my understanding, the cache in _get_trials is taken at the beginning of the trial, so it is not appropriate to use that cache in best_trial. It can be a strange value since best_trial is expected to be called after the trial.

feasible_trials = _get_feasible_trials(complete_trials)
if len(feasible_trials) == 0:
raise ValueError("No feasible trials are completed yet.")
if self.direction == StudyDirection.MAXIMIZE:
best_trial = max(feasible_trials, key=lambda t: cast(float, t.value))
else:
best_trial = min(feasible_trials, key=lambda t: cast(float, t.value))

return copy.deepcopy(best_trial)

@property
def best_trials(self) -> list[FrozenTrial]:
Expand All @@ -172,7 +190,11 @@ def best_trials(self) -> list[FrozenTrial]:
A list of :class:`~optuna.trial.FrozenTrial` objects.
"""

return _get_pareto_front_trials(self)
# Check whether the study is constrained optimization.
trials = self.get_trials(deepcopy=False)
is_constrained = any((_CONSTRAINTS_KEY in trial.system_attrs) for trial in trials)

return _get_pareto_front_trials(self, consider_constraint=is_constrained)

@property
def direction(self) -> StudyDirection:
Expand Down
13 changes: 13 additions & 0 deletions tests/study_tests/test_constrained_optimization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from optuna.study._constrained_optimization import _CONSTRAINTS_KEY
from optuna.study._constrained_optimization import _get_feasible_trials
from optuna.trial import create_trial


def test_get_feasible_trials() -> None:
trials = []
trials.append(create_trial(value=0.0, system_attrs={_CONSTRAINTS_KEY: [0.0]}))
trials.append(create_trial(value=0.0, system_attrs={_CONSTRAINTS_KEY: [1.0]}))
trials.append(create_trial(value=0.0))
feasible_trials = _get_feasible_trials(trials)
assert len(feasible_trials) == 1
assert feasible_trials[0] == trials[0]
60 changes: 60 additions & 0 deletions tests/study_tests/test_study.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from optuna.exceptions import DuplicatedStudyError
from optuna.exceptions import ExperimentalWarning
from optuna.study import StudyDirection
from optuna.study._constrained_optimization import _CONSTRAINTS_KEY
from optuna.study.study import _SYSTEM_ATTR_METRIC_NAMES
from optuna.testing.objectives import fail_objective
from optuna.testing.storages import STORAGE_MODES
Expand Down Expand Up @@ -1166,6 +1167,38 @@ def objective(trial: Trial) -> list[float]:
assert len(trial.values) == n_objectives


@pytest.mark.parametrize("direction", [StudyDirection.MINIMIZE, StudyDirection.MAXIMIZE])
def test_best_trial_constrained_optimization(direction: StudyDirection) -> None:
study = create_study(direction=direction)
storage = study._storage

with pytest.raises(ValueError):
# No trials.
study.best_trial

trial = study.ask()
storage.set_trial_system_attr(trial._trial_id, _CONSTRAINTS_KEY, [1])
study.tell(trial, 0)
with pytest.raises(ValueError):
# No feasible trials.
study.best_trial

trial = study.ask()
storage.set_trial_system_attr(trial._trial_id, _CONSTRAINTS_KEY, [0])
study.tell(trial, 0)
assert study.best_trial.number == 1

trial = study.ask()
storage.set_trial_system_attr(trial._trial_id, _CONSTRAINTS_KEY, [1])
study.tell(trial, -1 if direction == StudyDirection.MINIMIZE else 1)
assert study.best_trial.number == 1

trial = study.ask()
storage.set_trial_system_attr(trial._trial_id, _CONSTRAINTS_KEY, [0])
study.tell(trial, -1 if direction == StudyDirection.MINIMIZE else 1)
assert study.best_trial.number == 3


def test_best_trials() -> None:
study = create_study(directions=["minimize", "maximize"])
study.optimize(lambda t: [2, 2], n_trials=1)
Expand All @@ -1174,6 +1207,33 @@ def test_best_trials() -> None:
assert {tuple(t.values) for t in study.best_trials} == {(1, 1), (2, 2)}


def test_best_trials_constrained_optimization() -> None:
study = create_study(directions=["minimize", "maximize"])
storage = study._storage

assert study.best_trials == []

trial = study.ask()
storage.set_trial_system_attr(trial._trial_id, _CONSTRAINTS_KEY, [1])
study.tell(trial, [0, 0])
assert study.best_trials == []

trial = study.ask()
storage.set_trial_system_attr(trial._trial_id, _CONSTRAINTS_KEY, [0])
study.tell(trial, [0, 0])
assert study.best_trials == [study.trials[1]]

trial = study.ask()
storage.set_trial_system_attr(trial._trial_id, _CONSTRAINTS_KEY, [1])
study.tell(trial, [-1, 1])
assert study.best_trials == [study.trials[1]]

trial = study.ask()
storage.set_trial_system_attr(trial._trial_id, _CONSTRAINTS_KEY, [0])
study.tell(trial, [1, 1])
assert {t.number for t in study.best_trials} == {1, 3}


def test_wrong_n_objectives() -> None:
n_objectives = 2
directions = ["minimize" for _ in range(n_objectives)]
Expand Down