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

Add log-linear algorithm for 2d Pareto front. #2503

Merged
merged 5 commits into from Mar 25, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
47 changes: 46 additions & 1 deletion optuna/_multi_objective.py
Expand Up @@ -8,7 +8,46 @@
from optuna.trial import TrialState


def _get_pareto_front_trials(study: "optuna.study.BaseStudy") -> List[FrozenTrial]:
def _get_pareto_front_trials_2d(study: "optuna.study.BaseStudy") -> List[FrozenTrial]:
Copy link
Member

Choose a reason for hiding this comment

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

How about sorting the trials themself, and using _dominates function as done in _get_pareto_front_trials_nd?

    pareto_front = []
    trials = [trial for trial in study.trials if trial.state == TrialState.COMPLETE]
    trials.sort(key=lambda t: _normalize_value(t.values[1], study.directions[1]))
    trials.sort(key=lambda t: _normalize_value(t.values[0], study.directions[0]))

    last_nondominated_trial: Optional[FrozenTrial] = None
    for i in range(len(trials)):
        if i == 0:
            pareto_front.append(trials[0])
            last_nondominated_trial = trials[0]
            continue

        assert last_nondominated_trial is not None
        if not _dominates(last_nondominated_trial, trials[i], study.directions):
            pareto_front.append(trials[i])
            last_nondominated_trial = trials[i]

This code does not preserve the order of all trials and pareto front trials. so it will be fixed.

Copy link
Contributor Author

@parsiad parsiad Mar 24, 2021

Choose a reason for hiding this comment

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

I like the idea of using _dominates to simplify the code. I implemented your suggestion.

The only difference between my implementation and your suggestion is that I am still using mask because the order of trials needs to be preserved when filtering (as you point out in your comment).

Copy link
Member

Choose a reason for hiding this comment

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

To preserve the order of trials, we only need to sort the Pareto front trials with its trial.number. Could you remove the mask? (I have noticed this simple fix after posting the above messages. Sorry.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nice catch. Done.

trials = [trial for trial in study.trials if trial.state == TrialState.COMPLETE]
points = sorted(
(
+_normalize_value(trial.values[0], study.directions[0]),
-_normalize_value(trial.values[1], study.directions[1]),
index,
)
for index, trial in enumerate(trials)
)

mask = [False] * len(trials)

def set_mask(width: int, hi: int) -> None:
for k in range(hi - width, hi):
_, _, index = points[k]
mask[index] = True

width = 0
best_y = float("inf")
curr_x = float("nan")
for i, (x, y, _) in enumerate(points):
y = -y
if curr_x != x:
set_mask(width, hi=i)
width = 0
if y > best_y or (y == best_y and width == 0):
continue
if y < best_y:
width = 0
width += 1
best_y = y
curr_x = x
set_mask(width, hi=len(points))

pareto_front = [trial for trial, keep in zip(trials, mask) if keep]
return pareto_front


def _get_pareto_front_trials_nd(study: "optuna.study.BaseStudy") -> List[FrozenTrial]:
pareto_front = []
trials = [t for t in study.trials if t.state == TrialState.COMPLETE]

Expand All @@ -26,6 +65,12 @@ def _get_pareto_front_trials(study: "optuna.study.BaseStudy") -> List[FrozenTria
return pareto_front


def _get_pareto_front_trials(study: "optuna.study.BaseStudy") -> List[FrozenTrial]:
if len(study.directions) == 2:
return _get_pareto_front_trials_2d(study) # Log-linear in number of trials.
return _get_pareto_front_trials_nd(study) # Quadratic in number of trials.
Copy link
Member

Choose a reason for hiding this comment

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

The non-2d scenario seems not to be covered by any test case in test_study.py. We may want to add new ones, since it's risky to leave the general logic not tested.

Copy link
Contributor Author

@parsiad parsiad Mar 24, 2021

Choose a reason for hiding this comment

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

Added a 3d test case.



def _dominates(
trial0: FrozenTrial, trial1: FrozenTrial, directions: Sequence[StudyDirection]
) -> bool:
Expand Down
3 changes: 3 additions & 0 deletions tests/study_tests/test_study.py
Expand Up @@ -831,6 +831,9 @@ def _trial_to_values(t: FrozenTrial) -> Tuple[float, ...]:
study.optimize(lambda t: [3, 1], n_trials=1)
assert {_trial_to_values(t) for t in study.best_trials} == {(1, 1), (2, 2)}

study.optimize(lambda t: [3, 2], n_trials=1)
assert {_trial_to_values(t) for t in study.best_trials} == {(1, 1), (2, 2)}

study.optimize(lambda t: [1, 3], n_trials=1)
assert {_trial_to_values(t) for t in study.best_trials} == {(1, 3)}
assert len(study.best_trials) == 1
Expand Down