|
| 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