Repository: optuna/optuna

Issue:
X-axis of plotly's `plot_slice` is broken with categorical values
### Expected behavior

matplotlib:
<img width="553" alt="image" src="https://github.com/optuna/optuna/assets/23695091/e9012236-5a39-4642-ac66-68a049165502">

plotly:<img width="1327" alt="image" src="https://github.com/optuna/optuna/assets/23695091/22f0cb61-e856-43f5-9c7b-fa28a578d258">

The categorical value "a" should be displayed on the x-axis like the matplotlib version.

### Environment

- Optuna version: current master
- Python version: 3.9.6
- OS: macOS
- (Optional) Other libraries and their versions:


### Error messages, stack traces, or logs

```shell
The categorical value "a" is not shown on the x-axis (See above figure).
```


### Steps to reproduce

```python
import optuna


def objective(trial):
    x = trial.suggest_categorical("x", (0, "a"))
    if x == 0:
        return 0
    else:
        return 1


study = optuna.create_study()
study.optimize(objective, n_trials=10)
optuna.visualization.plot_slice(study).show()
```


### Additional context (optional)

_No response_


Discussion:

Resolving patch:
diff --git a/optuna/visualization/_slice.py b/optuna/visualization/_slice.py
--- a/optuna/visualization/_slice.py
+++ b/optuna/visualization/_slice.py
@@ -5,6 +5,8 @@
 from typing import cast
 from typing import NamedTuple
 
+from optuna.distributions import CategoricalChoiceType
+from optuna.distributions import CategoricalDistribution
 from optuna.logging import get_logger
 from optuna.study import Study
 from optuna.trial import FrozenTrial
@@ -13,7 +15,6 @@
 from optuna.visualization._utils import _check_plot_args
 from optuna.visualization._utils import _filter_nonfinite
 from optuna.visualization._utils import _is_log_scale
-from optuna.visualization._utils import _is_numerical
 
 
 if _imports.is_successful():
@@ -32,6 +33,7 @@ class _SliceSubplotInfo(NamedTuple):
     trial_numbers: list[int]
     is_log: bool
     is_numerical: bool
+    x_labels: tuple[CategoricalChoiceType, ...] | None
 
 
 class _SlicePlotInfo(NamedTuple):
@@ -45,6 +47,7 @@ def _get_slice_subplot_info(
     target: Callable[[FrozenTrial], float] | None,
     log_scale: bool,
     numerical: bool,
+    x_labels: tuple[CategoricalChoiceType, ...] | None,
 ) -> _SliceSubplotInfo:
     if target is None:
 
@@ -60,6 +63,7 @@ def _target(t: FrozenTrial) -> float:
         trial_numbers=[t.number for t in trials if param in t.params],
         is_log=log_scale,
         is_numerical=numerical,
+        x_labels=x_labels,
     )
 
 
@@ -80,6 +84,18 @@ def _get_slice_plot_info(
         return _SlicePlotInfo(target_name, [])
 
     all_params = {p_name for t in trials for p_name in t.params.keys()}
+
+    distributions = {}
+    for trial in trials:
+        for param_name, distribution in trial.distributions.items():
+            if param_name not in distributions:
+                distributions[param_name] = distribution
+
+    x_labels = {}
+    for param_name, distribution in distributions.items():
+        if isinstance(distribution, CategoricalDistribution):
+            x_labels[param_name] = distribution.choices
+
     if params is None:
         sorted_params = sorted(all_params)
     else:
@@ -96,7 +112,8 @@ def _get_slice_plot_info(
                 param=param,
                 target=target,
                 log_scale=_is_log_scale(trials, param),
-                numerical=_is_numerical(trials, param),
+                numerical=not isinstance(distributions[param], CategoricalDistribution),
+                x_labels=x_labels.get(param),
             )
             for param in sorted_params
         ],
@@ -167,7 +184,11 @@ def _get_slice_plot(info: _SlicePlotInfo) -> "go.Figure":
         figure = go.Figure(data=[_generate_slice_subplot(info.subplots[0])], layout=layout)
         figure.update_xaxes(title_text=info.subplots[0].param_name)
         figure.update_yaxes(title_text=info.target_name)
-        if info.subplots[0].is_log:
+        if not info.subplots[0].is_numerical:
+            figure.update_xaxes(
+                type="category", categoryorder="array", categoryarray=info.subplots[0].x_labels
+            )
+        elif info.subplots[0].is_log:
             figure.update_xaxes(type="log")
     else:
         figure = make_subplots(rows=1, cols=len(info.subplots), shared_yaxes=True)
@@ -182,7 +203,15 @@ def _get_slice_plot(info: _SlicePlotInfo) -> "go.Figure":
             figure.update_xaxes(title_text=subplot_info.param_name, row=1, col=column_index)
             if column_index == 1:
                 figure.update_yaxes(title_text=info.target_name, row=1, col=column_index)
-            if subplot_info.is_log:
+            if not subplot_info.is_numerical:
+                figure.update_xaxes(
+                    type="category",
+                    categoryorder="array",
+                    categoryarray=subplot_info.x_labels,
+                    row=1,
+                    col=column_index,
+                )
+            elif subplot_info.is_log:
                 figure.update_xaxes(type="log", row=1, col=column_index)
         if len(info.subplots) > 3:
             # Ensure that each subplot has a minimum width without relying on autusizing.
diff --git a/optuna/visualization/matplotlib/_slice.py b/optuna/visualization/matplotlib/_slice.py
--- a/optuna/visualization/matplotlib/_slice.py
+++ b/optuna/visualization/matplotlib/_slice.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+from collections import defaultdict
 import math
 from typing import Any
 from typing import Callable
@@ -127,19 +128,31 @@ def _generate_slice_subplot(
     target_name: str,
 ) -> "PathCollection":
     ax.set(xlabel=subplot_info.param_name, ylabel=target_name)
-    x_values = subplot_info.x
     scale = None
     if subplot_info.is_log:
         ax.set_xscale("log")
         scale = "log"
-    elif not subplot_info.is_numerical:
-        x_values = [str(x) for x in subplot_info.x]
+    if subplot_info.is_numerical:
+        x_values = subplot_info.x
+        y_values = subplot_info.y
+        c_values = subplot_info.trial_numbers
+    else:
+        x_values = []
+        y_values = []
+        c_values = []
+        assert subplot_info.x_labels is not None
+        points_dict = defaultdict(list)
+        for x, y, number in zip(subplot_info.x, subplot_info.y, subplot_info.trial_numbers):
+            points_dict[x].append((y, number))
+        for x_label in subplot_info.x_labels:
+            for y, number in points_dict[x_label]:
+                x_values.append(str(x_label))
+                y_values.append(y)
+                c_values.append(number)
         scale = "categorical"
     xlim = _calc_lim_with_padding(x_values, padding_ratio, scale)
     ax.set_xlim(xlim[0], xlim[1])
-    sc = ax.scatter(
-        x_values, subplot_info.y, c=subplot_info.trial_numbers, cmap=cmap, edgecolors="grey"
-    )
+    sc = ax.scatter(x_values, y_values, c=c_values, cmap=cmap, edgecolors="grey")
     ax.label_outer()
 
     return sc


Test patch:
diff --git a/tests/visualization_tests/test_slice.py b/tests/visualization_tests/test_slice.py
--- a/tests/visualization_tests/test_slice.py
+++ b/tests/visualization_tests/test_slice.py
@@ -169,6 +169,7 @@ def test_get_slice_plot_info_params(params: list[str] | None) -> None:
             trial_numbers=[0, 2],
             is_log=False,
             is_numerical=True,
+            x_labels=None,
         ),
         "param_b": _SliceSubplotInfo(
             param_name="param_b",
@@ -177,6 +178,7 @@ def test_get_slice_plot_info_params(params: list[str] | None) -> None:
             trial_numbers=[0, 1, 2],
             is_log=False,
             is_numerical=True,
+            x_labels=None,
         ),
         "param_c": _SliceSubplotInfo(
             param_name="param_c",
@@ -185,6 +187,7 @@ def test_get_slice_plot_info_params(params: list[str] | None) -> None:
             trial_numbers=[0, 2],
             is_log=False,
             is_numerical=True,
+            x_labels=None,
         ),
         "param_d": _SliceSubplotInfo(
             param_name="param_d",
@@ -193,6 +196,7 @@ def test_get_slice_plot_info_params(params: list[str] | None) -> None:
             trial_numbers=[0, 1, 2],
             is_log=False,
             is_numerical=True,
+            x_labels=None,
         ),
     }
 
@@ -222,6 +226,7 @@ def test_get_slice_plot_info_customized_target() -> None:
                 trial_numbers=[0, 2],
                 is_log=False,
                 is_numerical=True,
+                x_labels=None,
             ),
         ],
     )
@@ -265,6 +270,7 @@ def test_get_slice_plot_info_for_few_observations(params: list[str]) -> None:
                 trial_numbers=[0],
                 is_log=False,
                 is_numerical=True,
+                x_labels=None,
             ),
             _SliceSubplotInfo(
                 param_name="param_b",
@@ -273,6 +279,7 @@ def test_get_slice_plot_info_for_few_observations(params: list[str]) -> None:
                 trial_numbers=[0, 1],
                 is_log=False,
                 is_numerical=True,
+                x_labels=None,
             ),
         ],
     )
@@ -281,6 +288,8 @@ def test_get_slice_plot_info_for_few_observations(params: list[str]) -> None:
 def test_get_slice_plot_info_log_scale_and_str_category_2_params() -> None:
     study = _create_study_with_log_scale_and_str_category_2d()
     info = _get_slice_plot_info(study, None, None, "Objective Value")
+    distribution_b = study.trials[0].distributions["param_b"]
+    assert isinstance(distribution_b, CategoricalDistribution)
     assert info == _SlicePlotInfo(
         target_name="Objective Value",
         subplots=[
@@ -291,6 +300,7 @@ def test_get_slice_plot_info_log_scale_and_str_category_2_params() -> None:
                 trial_numbers=[0, 1],
                 is_log=True,
                 is_numerical=True,
+                x_labels=None,
             ),
             _SliceSubplotInfo(
                 param_name="param_b",
@@ -299,6 +309,7 @@ def test_get_slice_plot_info_log_scale_and_str_category_2_params() -> None:
                 trial_numbers=[0, 1],
                 is_log=False,
                 is_numerical=False,
+                x_labels=distribution_b.choices,
             ),
         ],
     )
@@ -307,6 +318,10 @@ def test_get_slice_plot_info_log_scale_and_str_category_2_params() -> None:
 def test_get_slice_plot_info_mixture_category_types() -> None:
     study = _create_study_mixture_category_types()
     info = _get_slice_plot_info(study, None, None, "Objective Value")
+    distribution_a = study.trials[0].distributions["param_a"]
+    distribution_b = study.trials[0].distributions["param_b"]
+    assert isinstance(distribution_a, CategoricalDistribution)
+    assert isinstance(distribution_b, CategoricalDistribution)
     assert info == _SlicePlotInfo(
         target_name="Objective Value",
         subplots=[
@@ -317,6 +332,7 @@ def test_get_slice_plot_info_mixture_category_types() -> None:
                 trial_numbers=[0, 1],
                 is_log=False,
                 is_numerical=False,
+                x_labels=distribution_a.choices,
             ),
             _SliceSubplotInfo(
                 param_name="param_b",
@@ -324,7 +340,8 @@ def test_get_slice_plot_info_mixture_category_types() -> None:
                 y=[0.0, 0.5],
                 trial_numbers=[0, 1],
                 is_log=False,
-                is_numerical=True,
+                is_numerical=False,
+                x_labels=distribution_b.choices,
             ),
         ],
     )
@@ -346,6 +363,7 @@ def test_get_slice_plot_info_nonfinite_removed(value: float) -> None:
                 trial_numbers=[1, 2],
                 is_log=False,
                 is_numerical=True,
+                x_labels=None,
             ),
             _SliceSubplotInfo(
                 param_name="param_d",
@@ -354,6 +372,7 @@ def test_get_slice_plot_info_nonfinite_removed(value: float) -> None:
                 trial_numbers=[1, 2],
                 is_log=False,
                 is_numerical=True,
+                x_labels=None,
             ),
         ],
     )
@@ -379,6 +398,7 @@ def test_get_slice_plot_info_nonfinite_multiobjective(objective: int, value: flo
                 trial_numbers=[1, 2],
                 is_log=False,
                 is_numerical=True,
+                x_labels=None,
             ),
             _SliceSubplotInfo(
                 param_name="param_d",
@@ -387,6 +407,7 @@ def test_get_slice_plot_info_nonfinite_multiobjective(objective: int, value: flo
                 trial_numbers=[1, 2],
                 is_log=False,
                 is_numerical=True,
+                x_labels=None,
             ),
         ],
     )