Skip to content

Commit 330f27d

Browse files
authored
Merge pull request #312 from nabenabe0928/add-eaf-plot
Add empirical attainment surface
2 parents 9632402 + d450816 commit 330f27d

File tree

7 files changed

+801
-0
lines changed

7 files changed

+801
-0
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 Shuhei Watanabe
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
---
2+
author: Shuhei Watanabe
3+
title: Visualizing Variability of Pareto Fronts over Multiple Runs (Empirical Attainment Surface)
4+
description: This module enables visualizing the uncertainty bands for bi-objective problems.
5+
tags: [visualization, multi-objective optimization, empirical attainment surface]
6+
optuna_versions: [4.5.0]
7+
license: MIT License
8+
---
9+
10+
## Abstract
11+
12+
Hyperparameter optimization is crucial to achieving high performance in deep learning.
13+
On top of the performance, other criteria such as inference time or memory requirement often need to be optimized due to some practical reasons.
14+
This motivates research on multi-objective optimization (MOO).
15+
However, Pareto fronts of MOO methods are often shown without considering the variability caused by random seeds, making the performance stability evaluation difficult.
16+
This package provides empirical attainment surface implementation based on [the original implementation](https://github.com/nabenabe0928/empirical-attainment-func).
17+
18+
The details of empirical attainment surface are available in [`Python Tool for Visualizing Variability of Pareto Fronts over Multiple Runs`](https://arxiv.org/abs/2305.08852).
19+
20+
## APIs
21+
22+
- `plot_empirical_attainment_surface(study_list: list[optuna.Study], attainment_ratios: list[int], ax: plt.Axes | None = None, color: str | None = None, label: str | None = None, linestyle: str | None = None, marker: str | None = None, log_scale_inds: list[int] | None = None, **ax_plot_kwargs)`
23+
- `study_list`: A list of studies used for the uncertainty visualization of the attainment surface.
24+
- `attainment_ratios`: The ratios of studies that need to dominate the surface. The length must be either 1 or 3. If the length is 1, the corresponding attainment surface will be visualized. If the length is 3, the area between the attainment surfaces with the lowest and the highest ratios will be visualized as the uncertainty.
25+
- `ax`: The axes of Matplotlib.
26+
- `color`: The color of the empirical attainment surface.
27+
- `label`: The label of the empirical attainment surface.
28+
- `linestyle`: The line style of the empirical attainment surface.
29+
- `marker`: The marker of the empirical attainment surface.
30+
- `log_scale_inds`: The indices of values that should be in the log scale. Either 0 or 1 can be specified.
31+
- `**ax_plot_kwargs`: The `kwargs` input to `ax.plot`.
32+
- `plot_multiple_empirical_attainment_surfaces(multiple_study_list: list[list[optuna.Study]], attainment_ratios: list[int], ax: plt.Axes | None = None, colors: list[str] | None = None, labels: list[str] | None = None, linestyles: list[str] | None = None, markers: list[str] | None = None, log_scale_inds: list[int] | None = None, **ax_plot_kwargs)`
33+
- `multiple_study_list`: A list of study list used for the uncertainty visualization of each attainment surface.
34+
- `attainment_ratios`: The ratios of studies that need to dominate the surface. The length must be either 1 or 3. If the length is 1, the corresponding attainment surface will be visualized. If the length is 3, the area between the attainment surfaces with the lowest and the highest ratios will be visualized as the uncertainty.
35+
- `ax`: The axes of Matplotlib.
36+
- `colors`: A list of the color of each empirical attainment surface.
37+
- `labels`: A list of the label of each empirical attainment surface.
38+
- `linestyles`: A list of the line style of each empirical attainment surface.
39+
- `markers`: A list of the marker of each empirical attainment surface.
40+
- `log_scale_inds`: The indices of values that should be in the log scale. Either 0 or 1 can be specified.
41+
- `**ax_plot_kwargs`: The `kwargs` input to `ax.plot`.
42+
43+
A typical `attainment_ratios` is `[0.25, 0.5, 0.75]` for the uncertainty visualization and `[0.5]` for the representative Pareto front.
44+
45+
## Installation
46+
47+
```shell
48+
$ pip install optuna matplotlib
49+
```
50+
51+
## Example
52+
53+
**Example**
54+
55+
```python
56+
from __future__ import annotations
57+
58+
import matplotlib.pyplot as plt
59+
import optuna
60+
import optunahub
61+
62+
63+
def objective(trial: optuna.Trial) -> tuple[float, float]:
64+
x = trial.suggest_float("x", -5, 5)
65+
y = trial.suggest_float("y", -5, 5)
66+
return x**2 + y**2, (x - 2)**2 + (y - 2)**2
67+
68+
multiple_study_list = []
69+
for sampler_cls in [optuna.samplers.RandomSampler, optuna.samplers.TPESampler]:
70+
study_list = []
71+
for seed in range(10):
72+
sampler = sampler_cls(seed=seed)
73+
study = optuna.create_study(directions=["minimize", "minimize"], sampler=sampler)
74+
study.optimize(objective, n_trials=50)
75+
study_list.append(study)
76+
multiple_study_list.append(study_list)
77+
78+
plot_multiple_empirical_attainment_surfaces = optunahub.load_module(
79+
"visualization/plot_empirical_attainment_surface"
80+
).plot_multiple_empirical_attainment_surfaces
81+
ax = plot_multiple_empirical_attainment_surfaces(
82+
multiple_study_list, attainment_ratios=[0.25, 0.5, 0.75], labels=["Random", "TPE"]
83+
)
84+
plt.show()
85+
```
86+
87+
## Others
88+
89+
### Bibtex
90+
91+
Please cite [our paper](https://arxiv.org/abs/2305.08852), when using this module.
92+
93+
```bibtex
94+
@article{watanabe2023python,
95+
title={{P}ython tool for visualizing variability of {P}areto fronts over multiple runs},
96+
author={Watanabe, Shuhei},
97+
journal={arXiv preprint arXiv:2305.08852},
98+
year={2023}
99+
}
100+
```
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from ._wrapper import plot_empirical_attainment_surface
2+
from ._wrapper import plot_multiple_empirical_attainment_surfaces
3+
4+
5+
__all__ = ["plot_empirical_attainment_surface", "plot_multiple_empirical_attainment_surfaces"]
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
from __future__ import annotations
2+
3+
import numpy as np
4+
import optuna
5+
6+
7+
EPS = 1e-300
8+
9+
10+
def _compute_emp_att_surf(
11+
X: np.ndarray, pareto_list: list[np.ndarray], levels: np.ndarray
12+
) -> np.ndarray:
13+
"""
14+
Compute the empirical attainment surface of the given Pareto front sets.
15+
16+
Args:
17+
x:
18+
The first objective values appeared in pareto_list.
19+
This array is sorted in the ascending order.
20+
The shape is (number of possible values, ).
21+
levels:
22+
A list of `level` described below:
23+
Control the k in the k-% attainment surface.
24+
k = level / n_independent_runs
25+
must hold.
26+
level must be in [1, n_independent_runs].
27+
level=1 leads to the best attainment surface,
28+
level=n_independent_runs leads to the worst attainment surface,
29+
level=n_independent_runs//2 leads to the median attainment surface.
30+
pareto_list:
31+
The list of the Pareto front sets.
32+
The shape is (trial number, Pareto solution index, objective index).
33+
Note that each pareto front set is sorted based on the ascending order of
34+
the first objective.
35+
36+
Returns:
37+
emp_att_surfs (np.ndarray):
38+
The vertices of the empirical attainment surfaces for each level.
39+
If emp_att_surf[i, j, 1] takes np.inf, this is not actually on the surface.
40+
The shape is (levels.size, X.size, 2).
41+
42+
Reference:
43+
Title: On the Computation of the Empirical Attainment Function
44+
Authors: Carlos M. Fonseca et al.
45+
https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.705.1929&rep=rep1&type=pdf
46+
47+
NOTE:
48+
Our algorithm is slightly different from the original one, but the result will be same.
49+
More details below:
50+
When we define N = n_independent_runs, K = X.size, and S = n_samples,
51+
the original algorithm requires O(NK + K log K)
52+
and this algorithm requires O(NK log K).
53+
Although our algorithm is a bit worse than the original algorithm,
54+
since the enumerating Pareto solutions requires O(NS log S),
55+
which might be smaller complexity but will take more time in Python,
56+
the time complexity will not dominate the whole process.
57+
"""
58+
n_levels = len(levels)
59+
emp_att_surfs = np.zeros((n_levels, X.size, 2))
60+
emp_att_surfs[..., 0] = X
61+
n_independent_runs = len(pareto_list)
62+
y_candidates = np.zeros((X.size, n_independent_runs))
63+
for i, pf_set in enumerate(pareto_list):
64+
ub = np.searchsorted(pf_set[:, 0], X, side="right")
65+
y_min = np.minimum.accumulate(np.hstack([np.inf, pf_set[:, 1]]))
66+
y_candidates[:, i] = y_min[ub]
67+
else:
68+
y_candidates = np.sort(y_candidates, axis=-1)
69+
70+
y_sol = y_candidates[:, levels - 1].T
71+
emp_att_surfs[..., 1] = y_sol
72+
73+
for emp_att_surf in emp_att_surfs:
74+
idx = np.sum(emp_att_surf[:, 1] == np.inf)
75+
emp_att_surf[:idx, 0] = emp_att_surf[idx, 0]
76+
77+
return emp_att_surfs
78+
79+
80+
def _get_empirical_attainment_surfaces(
81+
study_list: list[optuna.Study], levels: list[int], log_scale_inds: list[int] | None = None
82+
) -> np.ndarray:
83+
"""
84+
Get the empirical attainment surfaces given a list of studies.
85+
86+
Args:
87+
study_list:
88+
A list of studies to visualize the empirical attainment function.
89+
levels:
90+
A list of `level` described below:
91+
Control the k in the k-% attainment surface.
92+
k = level / n_independent_runs
93+
must hold.
94+
level must be in [1, n_independent_runs].
95+
level=1 leads to the best attainment surface,
96+
level=n_independent_runs leads to the worst attainment surface,
97+
level=n_independent_runs//2 leads to the median attainment surface.
98+
log_scale_inds:
99+
The indices of the log scale.
100+
For example, if you would like to plot the first objective in the log scale,
101+
you need to feed log_scale_inds=[0].
102+
In principle, log_scale_inds changes the minimum value of the axes
103+
from -np.inf to a small positive value.
104+
105+
Returns:
106+
emp_att_surfs (np.ndarray):
107+
The surfaces attained by (level / n_independent_runs) * 100% (for each level in levels)
108+
of the trials. In other words, (level / n_independent_runs) * 100% of runs dominate or
109+
at least include those solutions in their Pareto front. Note that we only return the
110+
Pareto front of attained solutions.
111+
"""
112+
if any(len(study.directions) != 2 for study in study_list):
113+
raise NotImplementedError("Three or more objectives are not supported.")
114+
if not all(1 <= level <= len(study_list) for level in levels):
115+
raise ValueError(
116+
f"All elements in levels must be in [1, {len(study_list)=}], but got {levels=}."
117+
)
118+
if not np.all(np.maximum.accumulate(levels) == levels):
119+
raise ValueError(f"levels must be an increasing sequence, but got {levels}.")
120+
larger_is_better_objectives = [
121+
i
122+
for i, d in enumerate(study_list[0].directions)
123+
if d == optuna.study.StudyDirection.MAXIMIZE
124+
]
125+
pareto_list = []
126+
for study in study_list:
127+
trials = study._get_trials(deepcopy=False, states=(optuna.trial.TrialState.COMPLETE,))
128+
sign_ = np.ones(2, dtype=float)
129+
sign_[larger_is_better_objectives] = -1
130+
loss_vals = sign_ * np.array([t.values for t in trials])
131+
on_front = optuna.study._multi_objective._is_pareto_front(
132+
loss_vals, assume_unique_lexsorted=False
133+
)
134+
pareto_list.append(loss_vals[on_front])
135+
136+
log_scale_inds = log_scale_inds or []
137+
pareto_sols = np.vstack(pareto_list)
138+
X = np.unique(np.hstack([EPS if 0 in log_scale_inds else -np.inf, pareto_sols[:, 0], np.inf]))
139+
emp_att_surfs = _compute_emp_att_surf(X=X, pareto_list=pareto_list, levels=np.asarray(levels))
140+
emp_att_surfs[..., larger_is_better_objectives] *= -1
141+
if larger_is_better_objectives is not None and 0 in larger_is_better_objectives:
142+
emp_att_surfs = np.flip(emp_att_surfs, axis=1)
143+
144+
return emp_att_surfs

0 commit comments

Comments
 (0)