From cbdf017833e3bffff7c4026b62ff4c75137eed16 Mon Sep 17 00:00:00 2001 From: Naoto Mizuno Date: Tue, 16 Apr 2024 19:28:37 +0900 Subject: [PATCH 1/7] Support constrained optimization in best_trial --- optuna/study/study.py | 27 +++++++++++++-- tests/study_tests/test_study.py | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/optuna/study/study.py b/optuna/study/study.py index 6d876d5503..9707256037 100644 --- a/optuna/study/study.py +++ b/optuna/study/study.py @@ -48,6 +48,7 @@ ObjectiveFuncType = Callable[[trial_module.Trial], Union[float, Sequence[float]]] _SYSTEM_ATTR_METRIC_NAMES = "study:metric_names" +_CONSTRAINTS_KEY = "constraints" _logger = logging.get_logger(__name__) @@ -58,6 +59,15 @@ class _ThreadLocalStudyAttribute(threading.local): cached_all_trials: list["FrozenTrial"] | None = None +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 + + class Study: """A study corresponds to an optimization task, i.e., a set of trials. @@ -154,7 +164,20 @@ 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) + + 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]) + 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]: @@ -169,7 +192,7 @@ def best_trials(self) -> list[FrozenTrial]: A list of :class:`~optuna.trial.FrozenTrial` objects. """ - return _get_pareto_front_trials(self) + return _get_pareto_front_trials(self, consider_constraint=True) @property def direction(self) -> StudyDirection: diff --git a/tests/study_tests/test_study.py b/tests/study_tests/test_study.py index 657ccd8185..1321cdffc2 100644 --- a/tests/study_tests/test_study.py +++ b/tests/study_tests/test_study.py @@ -33,6 +33,7 @@ from optuna.exceptions import DuplicatedStudyError from optuna.exceptions import ExperimentalWarning from optuna.study import StudyDirection +from optuna.study.study 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 @@ -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) @@ -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)] From a9e4180215678c0e048e1241405bec80b4a70019 Mon Sep 17 00:00:00 2001 From: Naoto Mizuno Date: Wed, 1 May 2024 15:46:09 +0900 Subject: [PATCH 2/7] Add comment for constrained optimization in best_trial --- optuna/study/study.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/optuna/study/study.py b/optuna/study/study.py index 9707256037..5131e9f3be 100644 --- a/optuna/study/study.py +++ b/optuna/study/study.py @@ -166,6 +166,9 @@ def best_trial(self) -> FrozenTrial: 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]) From 0069ab42053c63477f4230a86bc1f64e36ef2c31 Mon Sep 17 00:00:00 2001 From: Naoto Mizuno Date: Wed, 8 May 2024 11:50:44 +0900 Subject: [PATCH 3/7] Update feasibility checking logic Co-authored-by: Shuhei Watanabe <47781922+nabenabe0928@users.noreply.github.com> --- optuna/study/study.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/optuna/study/study.py b/optuna/study/study.py index 5131e9f3be..e9c2ec27af 100644 --- a/optuna/study/study.py +++ b/optuna/study/study.py @@ -63,7 +63,7 @@ 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]): + if constraints is not None and all(x <= 0.0 for x in constraints): feasible_trials.append(trial) return feasible_trials From 796f8f9a9fb2e2911339a49c02a472d94b58e7ad Mon Sep 17 00:00:00 2001 From: Naoto Mizuno Date: Wed, 8 May 2024 11:53:25 +0900 Subject: [PATCH 4/7] Update feasibility checking logic --- optuna/study/_multi_objective.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/optuna/study/_multi_objective.py b/optuna/study/_multi_objective.py index bb23eb397b..fa96b9de58 100644 --- a/optuna/study/_multi_objective.py +++ b/optuna/study/_multi_objective.py @@ -18,7 +18,7 @@ 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]): + if constraints is not None and all(x <= 0.0 for x in constraints): feasible_trials.append(trial) return feasible_trials From 4a170d98964c1d7bd5d0d4658fc807286f384ebf Mon Sep 17 00:00:00 2001 From: Naoto Mizuno Date: Wed, 8 May 2024 12:19:12 +0900 Subject: [PATCH 5/7] Check whether constrained optimization --- optuna/study/study.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/optuna/study/study.py b/optuna/study/study.py index e9c2ec27af..d992aace19 100644 --- a/optuna/study/study.py +++ b/optuna/study/study.py @@ -195,7 +195,11 @@ def best_trials(self) -> list[FrozenTrial]: A list of :class:`~optuna.trial.FrozenTrial` objects. """ - return _get_pareto_front_trials(self, consider_constraint=True) + # 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: From d31447c52325df39d675cb4d302bdc2e687f5891 Mon Sep 17 00:00:00 2001 From: Naoto Mizuno Date: Mon, 20 May 2024 18:45:50 +0900 Subject: [PATCH 6/7] Organize implementations of constrained optimization --- optuna/study/_constrained_optimization.py | 25 +++++++++++++++++++ optuna/study/_multi_objective.py | 13 +--------- optuna/study/study.py | 12 ++------- .../test_constrained_optimization.py | 13 ++++++++++ tests/study_tests/test_study.py | 2 +- 5 files changed, 42 insertions(+), 23 deletions(-) create mode 100644 optuna/study/_constrained_optimization.py create mode 100644 tests/study_tests/test_constrained_optimization.py diff --git a/optuna/study/_constrained_optimization.py b/optuna/study/_constrained_optimization.py new file mode 100644 index 0000000000..9228d567b2 --- /dev/null +++ b/optuna/study/_constrained_optimization.py @@ -0,0 +1,25 @@ +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 diff --git a/optuna/study/_multi_objective.py b/optuna/study/_multi_objective.py index fa96b9de58..ab3bce1f20 100644 --- a/optuna/study/_multi_objective.py +++ b/optuna/study/_multi_objective.py @@ -6,23 +6,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 not None and all(x <= 0.0 for x in constraints): - feasible_trials.append(trial) - return feasible_trials - - def _get_pareto_front_trials_2d( trials: Sequence[FrozenTrial], directions: Sequence[StudyDirection], diff --git a/optuna/study/study.py b/optuna/study/study.py index d992aace19..bd49e66d7a 100644 --- a/optuna/study/study.py +++ b/optuna/study/study.py @@ -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 @@ -48,7 +50,6 @@ ObjectiveFuncType = Callable[[trial_module.Trial], Union[float, Sequence[float]]] _SYSTEM_ATTR_METRIC_NAMES = "study:metric_names" -_CONSTRAINTS_KEY = "constraints" _logger = logging.get_logger(__name__) @@ -59,15 +60,6 @@ class _ThreadLocalStudyAttribute(threading.local): cached_all_trials: list["FrozenTrial"] | None = None -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 not None and all(x <= 0.0 for x in constraints): - feasible_trials.append(trial) - return feasible_trials - - class Study: """A study corresponds to an optimization task, i.e., a set of trials. diff --git a/tests/study_tests/test_constrained_optimization.py b/tests/study_tests/test_constrained_optimization.py new file mode 100644 index 0000000000..fee8895aea --- /dev/null +++ b/tests/study_tests/test_constrained_optimization.py @@ -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] diff --git a/tests/study_tests/test_study.py b/tests/study_tests/test_study.py index 1321cdffc2..8509d627e6 100644 --- a/tests/study_tests/test_study.py +++ b/tests/study_tests/test_study.py @@ -33,7 +33,7 @@ from optuna.exceptions import DuplicatedStudyError from optuna.exceptions import ExperimentalWarning from optuna.study import StudyDirection -from optuna.study.study import _CONSTRAINTS_KEY +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 From 997ebe80f6916c608b4bcc99f461a6b1d768fa88 Mon Sep 17 00:00:00 2001 From: Naoto Mizuno Date: Tue, 21 May 2024 10:08:52 +0900 Subject: [PATCH 7/7] Add from __future__ import annotations --- optuna/study/_constrained_optimization.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/optuna/study/_constrained_optimization.py b/optuna/study/_constrained_optimization.py index 9228d567b2..d289e13f55 100644 --- a/optuna/study/_constrained_optimization.py +++ b/optuna/study/_constrained_optimization.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from collections.abc import Sequence from optuna.trial import FrozenTrial