Repository: optuna/optuna

Issue:
Add `trials_dataframe()` method for `MultiObjectiveStudy`
Add `trials_dataframe` method for `MultiObjectiveStudy`.

## Motivation
- Make it easier to visualize and handle `MultiObjectiveStudy`.

## Description
- `Study`'s `trials_dataframe()` method is so convenient. 
- If there are no problems, I would like to start implementing `trials_dataframe()` for `MultiObjectiveStudy`.
- If you have any ideas, etc., I would appreciate it if you could let me know.

## Reference
- [`MultiObjectiveStudy`](https://github.com/optuna/optuna/blob/cec7aea036b7abc2ad6ec8eb2599a7508bdd2360/optuna/multi_objective/study.py#L209)
-   `Study`'s [`trials_dataframe` method](https://github.com/optuna/optuna/blob/110851c7d2dbd8c80e63b71a6e897250f00121db/optuna/study.py#L438)
- [_dataframe.py](https://github.com/optuna/optuna/blob/110851c7d2dbd8c80e63b71a6e897250f00121db/optuna/_dataframe.py#L23)


Discussion:
sounds good. 
Thanks for the suggestion. Sounds good, just that the `MultiObjectiveStudy` is being deprecated in the next 2.4 release and the standard `Study` will instead support multi-objective optimization. I think that the support for multi-objective use cases with `Study.trials_dataframe` isn't yet implemented and that this might be more meaningful/higher priority.

Resolving patch:
diff --git a/optuna/_dataframe.py b/optuna/_dataframe.py
--- a/optuna/_dataframe.py
+++ b/optuna/_dataframe.py
@@ -31,6 +31,9 @@ def _trials_dataframe(
     if not len(trials):
         return pd.DataFrame()
 
+    if "value" in attrs and study._is_multi_objective():
+        attrs = tuple("values" if attr == "value" else attr for attr in attrs)
+
     attrs_to_df_columns: Dict[str, str] = collections.OrderedDict()
     for attr in attrs:
         if attr.startswith("_"):
@@ -60,6 +63,11 @@ def _create_record_and_aggregate_column(
                 for nested_attr, nested_value in value.items():
                     record[(df_column, nested_attr)] = nested_value
                     column_agg[attr].add((df_column, nested_attr))
+            elif isinstance(value, list):
+                # Expand trial.values.
+                for nested_attr, nested_value in enumerate(value):
+                    record[(df_column, nested_attr)] = nested_value
+                    column_agg[attr].add((df_column, nested_attr))
             else:
                 record[(df_column, non_nested_attr)] = value
                 column_agg[attr].add((df_column, non_nested_attr))
diff --git a/optuna/study.py b/optuna/study.py
--- a/optuna/study.py
+++ b/optuna/study.py
@@ -491,6 +491,10 @@ def objective(trial):
 
         .. _DataFrame: http://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.html
         .. _MultiIndex: https://pandas.pydata.org/pandas-docs/stable/advanced.html
+
+        Note:
+            If ``value`` is in ``attrs`` during multi-objective optimization, it is implicitly
+            replaced with ``values``.
         """
         return _trials_dataframe(self, attrs, multi_index)
 


Test patch:
diff --git a/tests/test_study.py b/tests/test_study.py
--- a/tests/test_study.py
+++ b/tests/test_study.py
@@ -570,6 +570,38 @@ def f(trial: optuna.trial.Trial) -> float:
             assert df.user_attrs_train_loss[i] == 3
 
 
+@pytest.mark.parametrize(
+    "attrs",
+    [
+        ("value",),
+        ("values",),
+    ],
+)
+@pytest.mark.parametrize("multi_index", [True, False])
+def test_trials_dataframe_with_multi_objective_optimization(
+    attrs: Tuple[str, ...], multi_index: bool
+) -> None:
+    def f(trial: optuna.trial.Trial) -> Tuple[float, float]:
+
+        x = trial.suggest_float("x", 1, 1)
+        y = trial.suggest_float("y", 2, 2)
+
+        return x + y, x ** 2 + y ** 2  # 3, 5
+
+    study = optuna.create_study(directions=["minimize", "maximize"])
+    study.optimize(f, n_trials=3)
+    df = study.trials_dataframe(attrs=attrs, multi_index=multi_index)
+
+    if multi_index:
+        for i in range(3):
+            assert df.get("values")[0][i] == 3
+            assert df.get("values")[1][i] == 5
+    else:
+        for i in range(3):
+            assert df.values_0[i] == 3
+            assert df.values_1[i] == 5
+
+
 @pytest.mark.parametrize("storage_mode", STORAGE_MODES)
 def test_create_study(storage_mode: str) -> None: