Repository: optuna/optuna

Issue:
Plot the hyperparameter importances for each objective value on a single bar chart.
### Motivation

When using `plot_param_importances()`  for multi-objective studies, `target` argument must be given to specify which objective value is used to compute hyperparameter importances.
https://optuna.readthedocs.io/en/stable/reference/visualization/generated/optuna.visualization.plot_param_importances.html#optuna.visualization.plot_param_importances

### Description

Optuna Dashboard can show hyperparameter importances for each objective value in a single bar chart.

> Before:
> <img width="1951" alt="before" src="https://user-images.githubusercontent.com/5564044/211761167-84787eb4-a984-4fe0-8fe4-1e7045e99506.png">
> After:
> <img width="979" alt="after" src="https://user-images.githubusercontent.com/5564044/211761147-495fe207-8552-4045-87bf-2c1ef5afc187.png">
> https://github.com/optuna/optuna-dashboard/pull/357

I think it's a good idea to show hyperparameter importances for each objective value when given a multi-objective study and `target=None` in Optuna as well.

### Alternatives (optional)

_No response_

### Additional context (optional)

Currently, `plot_param_importances()` raises an exception when given a multi-objective study with `target=None`.
https://github.com/optuna/optuna/blob/1aec995acfe7587f0e90034202817a1fd364007d/optuna/importance/_fanova/_evaluator.py#L86-L91


Discussion:

Resolving patch:
diff --git a/optuna/visualization/_param_importances.py b/optuna/visualization/_param_importances.py
--- a/optuna/visualization/_param_importances.py
+++ b/optuna/visualization/_param_importances.py
@@ -16,8 +16,6 @@
 
 
 if _imports.is_successful():
-    import plotly
-
     from optuna.visualization._plotly_imports import go
 
 
@@ -122,13 +120,16 @@ def objective(trial):
         target:
             A function to specify the value to display. If it is :obj:`None` and ``study`` is being
             used for single-objective optimization, the objective values are plotted.
+            For multi-objective optimization, all objectives will be plotted if ``target``
+            is :obj:`None`.
 
             .. note::
-                Specify this argument if ``study`` is being used for multi-objective
-                optimization. For example, to get the hyperparameter importance of the first
-                objective, use ``target=lambda t: t.values[0]`` for the target parameter.
+                This argument can be used to specify which objective to plot if ``study`` is being
+                used for multi-objective optimization. For example, to get only the hyperparameter
+                importance of the first objective, use ``target=lambda t: t.values[0]`` for the
+                target parameter.
         target_name:
-            Target's name to display on the axis label.
+            Target's name to display on the legend.
 
     Returns:
         A :class:`plotly.graph_objs.Figure` object.
@@ -136,42 +137,55 @@ def objective(trial):
 
     _imports.check()
 
-    importances_info = _get_importances_info(study, evaluator, params, target, target_name)
-    hover_template = _get_hover_template(importances_info, study)
-    return _get_importances_plot(importances_info, hover_template)
+    if target or not study._is_multi_objective():
+        importances_infos: tuple[_ImportancesInfo, ...] = (
+            _get_importances_info(
+                study, evaluator, params, target=target, target_name=target_name
+            ),
+        )
+
+    else:
+        n_objectives = len(study.directions)
+        importances_infos = tuple(
+            _get_importances_info(
+                study,
+                evaluator,
+                params,
+                target=lambda t: t.values[objective_id],
+                target_name=f"{target_name} {objective_id}",
+            )
+            for objective_id in range(n_objectives)
+        )
 
+    return _get_importances_plot(importances_infos, study)
 
-def _get_importances_plot(info: _ImportancesInfo, hover_template: list[str]) -> "go.Figure":
+
+def _get_importances_plot(infos: tuple[_ImportancesInfo, ...], study: Study) -> "go.Figure":
     layout = go.Layout(
         title="Hyperparameter Importances",
-        xaxis={"title": f"Importance for {info.target_name}"},
+        xaxis={"title": "Hyperparameter Importance"},
         yaxis={"title": "Hyperparameter"},
-        showlegend=False,
     )
 
-    param_names = info.param_names
-    importance_values = info.importance_values
-
-    if len(importance_values) == 0:
-        return go.Figure(data=[], layout=layout)
+    data: list[go.Bar] = []
+    for info in infos:
+        if not info.importance_values:
+            continue
 
-    fig = go.Figure(
-        data=[
+        data.append(
             go.Bar(
-                x=importance_values,
-                y=param_names,
+                x=info.importance_values,
+                y=info.param_names,
+                name=info.target_name,
                 text=info.importance_labels,
                 textposition="outside",
                 cliponaxis=False,  # Ensure text is not clipped.
-                hovertemplate=hover_template,
-                marker_color=plotly.colors.sequential.Blues[-4],
+                hovertemplate=_get_hover_template(info, study),
                 orientation="h",
             )
-        ],
-        layout=layout,
-    )
+        )
 
-    return fig
+    return go.Figure(data, layout)
 
 
 def _get_distribution(param_name: str, study: Study) -> BaseDistribution:
diff --git a/optuna/visualization/matplotlib/_matplotlib_imports.py b/optuna/visualization/matplotlib/_matplotlib_imports.py
--- a/optuna/visualization/matplotlib/_matplotlib_imports.py
+++ b/optuna/visualization/matplotlib/_matplotlib_imports.py
@@ -13,6 +13,7 @@
     from matplotlib.collections import PathCollection
     from matplotlib.colors import Colormap
     from matplotlib.contour import ContourSet
+    from matplotlib.figure import Figure
 
     # TODO(ytknzw): Set precise version.
     if version.parse(matplotlib_version) < version.parse("3.0.0"):
@@ -34,4 +35,5 @@
     "PathCollection",
     "Colormap",
     "ContourSet",
+    "Figure",
 ]
diff --git a/optuna/visualization/matplotlib/_param_importances.py b/optuna/visualization/matplotlib/_param_importances.py
--- a/optuna/visualization/matplotlib/_param_importances.py
+++ b/optuna/visualization/matplotlib/_param_importances.py
@@ -16,6 +16,7 @@
 
 if _imports.is_successful():
     from optuna.visualization.matplotlib._matplotlib_imports import Axes
+    from optuna.visualization.matplotlib._matplotlib_imports import Figure
     from optuna.visualization.matplotlib._matplotlib_imports import plt
 
 
@@ -76,11 +77,14 @@ def objective(trial):
         target:
             A function to specify the value to display. If it is :obj:`None` and ``study`` is being
             used for single-objective optimization, the objective values are plotted.
+            For multi-objective optimization, all objectives will be plotted if ``target``
+            is :obj:`None`.
 
             .. note::
-                Specify this argument if ``study`` is being used for multi-objective
-                optimization. For example, to get the hyperparameter importance of the first
-                objective, use ``target=lambda t: t.values[0]`` for the target parameter.
+                This argument can be used to specify which objective to plot if ``study`` is being
+                used for multi-objective optimization. For example, to get only the hyperparameter
+                importance of the first objective, use ``target=lambda t: t.values[0]`` for the
+                target parameter.
         target_name:
             Target's name to display on the axis label.
 
@@ -90,37 +94,66 @@ def objective(trial):
 
     _imports.check()
 
-    importances_info = _get_importances_info(study, evaluator, params, target, target_name)
-    return _get_importances_plot(importances_info)
+    if target or not study._is_multi_objective():
+        importances_infos: tuple[_ImportancesInfo, ...] = (
+            _get_importances_info(study, evaluator, params, target, target_name),
+        )
 
+    else:
+        n_objectives = len(study.directions)
+        importances_infos = tuple(
+            _get_importances_info(
+                study,
+                evaluator,
+                params,
+                target=lambda t: t.values[objective_id],
+                target_name=f"{target_name} {objective_id}",
+            )
+            for objective_id in range(n_objectives)
+        )
 
-def _get_importances_plot(info: _ImportancesInfo) -> "Axes":
+    return _get_importances_plot(importances_infos)
+
+
+def _get_importances_plot(infos: tuple[_ImportancesInfo, ...]) -> "Axes":
     # Set up the graph style.
     plt.style.use("ggplot")  # Use ggplot style sheet for similar outputs to plotly.
     fig, ax = plt.subplots()
-    ax.set_title("Hyperparameter Importances")
-    ax.set_xlabel(f"Importance for {info.target_name}")
+    ax.set_title("Hyperparameter Importances", loc="left")
+    ax.set_xlabel("Hyperparameter Importance")
     ax.set_ylabel("Hyperparameter")
+    height = 0.8 / len(infos)  # Default height split between objectives.
+
+    for objective_id, info in enumerate(infos):
+        param_names = info.param_names
+        pos = np.arange(len(param_names))
+        offset = height * objective_id
+        importance_values = info.importance_values
+
+        if not importance_values:
+            continue
+
+        # Draw horizontal bars.
+        ax.barh(
+            pos + offset,
+            importance_values,
+            height=height,
+            align="center",
+            label=info.target_name,
+            color=plt.get_cmap("tab20c")(objective_id),
+        )
+
+        _set_bar_labels(info, fig, ax, offset)
+        ax.set_yticks(pos + offset / 2, param_names)
+
+    ax.legend(loc="best")
+    return ax
 
-    param_names = info.param_names
-    pos = np.arange(len(param_names))
-    importance_values = info.importance_values
-
-    if len(importance_values) == 0:
-        return ax
-
-    # Draw horizontal bars.
-    ax.barh(
-        pos,
-        importance_values,
-        align="center",
-        color=plt.get_cmap("tab20c")(0),
-        tick_label=param_names,
-    )
 
+def _set_bar_labels(info: _ImportancesInfo, fig: "Figure", ax: "Axes", offset: float) -> None:
     renderer = fig.canvas.get_renderer()
-    for idx, (val, label) in enumerate(zip(importance_values, info.importance_labels)):
-        text = ax.text(val, idx, label, va="center")
+    for idx, (val, label) in enumerate(zip(info.importance_values, info.importance_labels)):
+        text = ax.text(val, idx + offset, label, va="center")
 
         # Sometimes horizontal axis needs to be re-scaled
         # to avoid text going over plot area.
@@ -131,5 +164,3 @@ def _get_importances_plot(info: _ImportancesInfo) -> "Axes":
 
         if bbox_xmax > plot_xmax:
             ax.set_xlim(xmax=AXES_PADDING_RATIO * bbox_xmax)
-
-    return ax


Test patch:
diff --git a/tests/visualization_tests/test_param_importances.py b/tests/visualization_tests/test_param_importances.py
--- a/tests/visualization_tests/test_param_importances.py
+++ b/tests/visualization_tests/test_param_importances.py
@@ -37,6 +37,16 @@ def _create_study_with_failed_trial() -> Study:
     return study
 
 
+def _create_multiobjective_study_with_failed_trial() -> Study:
+    study = create_study(directions=["minimize", "minimize"])
+    study.optimize(fail_objective, n_trials=1, catch=(ValueError,))
+    return study
+
+
+def _create_multiobjective_study() -> Study:
+    return prepare_study_with_trials(n_objectives=2)
+
+
 def test_target_is_none_and_study_is_multi_obj() -> None:
     study = create_study(directions=["minimize", "minimize"])
     with pytest.raises(ValueError):
@@ -53,9 +63,23 @@ def test_plot_param_importances_customized_target_name(
     study = prepare_study_with_trials()
     figure = plot_param_importances(study, params=params, target_name="Target Name")
     if isinstance(figure, go.Figure):
-        assert figure.layout.xaxis.title.text == "Importance for Target Name"
+        assert figure.layout.xaxis.title.text == "Hyperparameter Importance"
+    elif isinstance(figure, Axes):
+        assert figure.figure.axes[0].get_xlabel() == "Hyperparameter Importance"
+
+
+@parametrize_plot_param_importances
+def test_plot_param_importances_multiobjective_all_objectives_displayed(
+    plot_param_importances: Callable[..., Any]
+) -> None:
+    n_objectives = 2
+    params = ["param_a"]
+    study = prepare_study_with_trials(n_objectives)
+    figure = plot_param_importances(study, params=params)
+    if isinstance(figure, go.Figure):
+        assert len(figure.data) == n_objectives
     elif isinstance(figure, Axes):
-        assert figure.figure.axes[0].get_xlabel() == "Importance for Target Name"
+        assert len(figure.patches) == n_objectives * len(params)
 
 
 @parametrize_plot_param_importances
@@ -63,7 +87,9 @@ def test_plot_param_importances_customized_target_name(
     "specific_create_study",
     [
         create_study,
+        _create_multiobjective_study,
         _create_study_with_failed_trial,
+        _create_multiobjective_study_with_failed_trial,
         prepare_study_with_trials,
     ],
 )