Repository: optuna/optuna

Issue:
Display best objective value in contour plot for a given param pair, not the value from the most recent trial
### Motivation

I am optimizing 'learning rate', 'encoder', and 'dropout'. I happen to have 4 trails that all have encoder:resnet34 & dropout:0.1, each with a different learning rate:

![image](https://github.com/optuna/optuna/assets/87331101/b6fd0100-8035-4f47-81b8-b8e236e9a69f)

The contour plot of 'encoder' vs. 'dropout' is displaying the objective value from the most recent trial (71) for resnet34 & dropout 0.1, not the best value (from trail 21). Ironically, trial 21 is my best overall trial, which is why I'm frustrated this isn't being reflected in the contour plot

![image](https://github.com/optuna/optuna/assets/87331101/46d8a803-93e6-4e77-a87c-39fae1f5b7ab)

This issue was mentioned in a comment here, but never followed up on https://github.com/optuna/optuna/issues/2524#issuecomment-970780116

### Description

When running a study with >2 parameters that are categorical and/or have step values, it is likely that there will be multiple trials with same combo of values for some of those parameters - i.e. in my example, there are 4 trials with resnet34 and dropout 0.1, but with different learning rates. Currently, the contour plot displays the objective value from the most recent trial.

Feature request: when generating contour plots, display the **_best_** objective value for a given combo of parameters, not the objective value from the most recent trial.

### Alternatives (optional)

It seems the only real work around at the moment is filtering by the best N trials, such as seen here https://github.com/optuna/optuna/issues/1402

This does work for my current issue, but the problem with this is that there's no way to ensure that I'm seeing the best performance across the full parameter space unless I do this independently for each parameter pair

![image](https://github.com/optuna/optuna/assets/87331101/cf43ea1f-46b4-4337-a2f3-c1b31b05fd41)


### Additional context (optional)

_No response_


Discussion:
Thanks for your feature suggestion! (and sorry for late reply 🙇‍♂️ )
I think this can be fixed by a relatively simple modification in https://github.com/optuna/optuna/blob/master/optuna/visualization/_contour.py#L209-L210 to retain the best trials. I'm not currently having enough time for this, but we welcome your contribution!

Resolving patch:
diff --git a/optuna/visualization/_contour.py b/optuna/visualization/_contour.py
--- a/optuna/visualization/_contour.py
+++ b/optuna/visualization/_contour.py
@@ -6,6 +6,7 @@
 
 from optuna.logging import get_logger
 from optuna.study import Study
+from optuna.study import StudyDirection
 from optuna.trial import FrozenTrial
 from optuna.trial import TrialState
 from optuna.visualization._plotly_imports import _imports
@@ -267,14 +268,14 @@ def _get_contour_info(
     if len(sorted_params) == 2:
         x_param = sorted_params[0]
         y_param = sorted_params[1]
-        sub_plot_info = _get_contour_subplot_info(trials, x_param, y_param, target)
+        sub_plot_info = _get_contour_subplot_info(study, trials, x_param, y_param, target)
         sub_plot_infos = [[sub_plot_info]]
     else:
         sub_plot_infos = []
         for i, y_param in enumerate(sorted_params):
             sub_plot_infos.append([])
             for x_param in sorted_params:
-                sub_plot_info = _get_contour_subplot_info(trials, x_param, y_param, target)
+                sub_plot_info = _get_contour_subplot_info(study, trials, x_param, y_param, target)
                 sub_plot_infos[i].append(sub_plot_info)
 
     reverse_scale = _is_reverse_scale(study, target)
@@ -288,6 +289,7 @@ def _get_contour_info(
 
 
 def _get_contour_subplot_info(
+    study: Study,
     trials: list[FrozenTrial],
     x_param: str,
     y_param: str,
@@ -306,7 +308,7 @@ def _get_contour_subplot_info(
         _logger.warning("Param {} unique value length is less than 2.".format(y_param))
         return _SubContourInfo(xaxis=xaxis, yaxis=yaxis, z_values={})
 
-    z_values = {}
+    z_values: dict[tuple[int, int], float] = {}
     for i, trial in enumerate(trials):
         if x_param not in trial.params or y_param not in trial.params:
             continue
@@ -323,7 +325,17 @@ def _get_contour_subplot_info(
             value = target(trial)
         assert value is not None
 
-        z_values[(x_i, y_i)] = value
+        existing = z_values.get((x_i, y_i))
+        if existing is None or target is not None:
+            # When target function is present, we can't be sure what the z-value
+            # represents and therefore we don't know how to select the best one.
+            z_values[(x_i, y_i)] = value
+        else:
+            z_values[(x_i, y_i)] = (
+                min(existing, value)
+                if study.direction is StudyDirection.MINIMIZE
+                else max(existing, value)
+            )
 
     return _SubContourInfo(xaxis=xaxis, yaxis=yaxis, z_values=z_values)
 


Test patch:
diff --git a/tests/visualization_tests/test_contour.py b/tests/visualization_tests/test_contour.py
--- a/tests/visualization_tests/test_contour.py
+++ b/tests/visualization_tests/test_contour.py
@@ -102,6 +102,37 @@ def _create_study_mixture_category_types() -> Study:
     return study
 
 
+def _create_study_with_overlapping_params(direction: str) -> Study:
+    study = create_study(direction=direction)
+    distributions = {
+        "param_a": FloatDistribution(1.0, 2.0),
+        "param_b": CategoricalDistribution(["100", "101"]),
+        "param_c": CategoricalDistribution(["foo", "bar"]),
+    }
+    study.add_trial(
+        create_trial(
+            value=0.0,
+            params={"param_a": 1.0, "param_b": "101", "param_c": "foo"},
+            distributions=distributions,
+        )
+    )
+    study.add_trial(
+        create_trial(
+            value=1.0,
+            params={"param_a": 1.0, "param_b": "101", "param_c": "bar"},
+            distributions=distributions,
+        )
+    )
+    study.add_trial(
+        create_trial(
+            value=1.0,
+            params={"param_a": 2.0, "param_b": "100", "param_c": "foo"},
+            distributions=distributions,
+        )
+    )
+    return study
+
+
 @parametrize_plot_contour
 def test_plot_contour_customized_target_name(plot_contour: Callable[..., Any]) -> None:
     params = ["param_a", "param_b"]
@@ -517,6 +548,40 @@ def test_get_contour_info_nonfinite_multiobjective(objective: int, value: float)
     )
 
 
+@pytest.mark.parametrize("direction,expected", (("minimize", 0.0), ("maximize", 1.0)))
+def test_get_contour_info_overlapping_params(direction: str, expected: float) -> None:
+    study = _create_study_with_overlapping_params(direction)
+    info = _get_contour_info(study, params=["param_a", "param_b"])
+    assert info == _ContourInfo(
+        sorted_params=["param_a", "param_b"],
+        sub_plot_infos=[
+            [
+                _SubContourInfo(
+                    xaxis=_AxisInfo(
+                        name="param_a",
+                        range=(0.95, 2.05),
+                        is_log=False,
+                        is_cat=False,
+                        indices=[0.95, 1.0, 2.0, 2.05],
+                        values=[1.0, 1.0, 2.0],
+                    ),
+                    yaxis=_AxisInfo(
+                        name="param_b",
+                        range=(-0.05, 1.05),
+                        is_log=False,
+                        is_cat=True,
+                        indices=["100", "101"],
+                        values=["101", "101", "100"],
+                    ),
+                    z_values={(1, 1): expected, (2, 0): 1.0},
+                )
+            ]
+        ],
+        reverse_scale=False if direction == "maximize" else True,
+        target_name="Objective Value",
+    )
+
+
 @pytest.mark.parametrize("direction", ["minimize", "maximize"])
 def test_color_map(direction: str) -> None:
     study = create_study(direction=direction)