Repository: optuna/optuna

Issue:
Constraint-aware pareto-front plot
<!-- Please write a clear and concise description of the feature proposal. -->

## Motivation

This is a sub-task of #2397.

## Description


Recently, Optuna enables us to add constraints to multi-objective optimization. The `plot_paret_front` function, however, does not consider whether the solution is feasible or not. So, I needed to modify it to visualize the constraint optimization.

For example, the following plot is an example of multi-objective knapsack problem. The objectives are to be maximized, but infeasible solutions are likely to be seen in the up-right region. In this case, I couldn't find the shape of Pareto front without removing the infeasible solutions.

![0199](https://user-images.githubusercontent.com/3255979/109284470-3cad0700-7863-11eb-82bc-63e48a530205.png)

We may have some design choices, but I think the first choice will be adding `constraints_func` to `plot_pareto_front`. This is because some samplers including [`NSGAIISampler`](https://optuna.readthedocs.io/en/stable/reference/generated/optuna.samplers.NSGAIISampler.html#optuna.samplers.NSGAIISampler) and [`BoTorchSampler`](https://optuna.readthedocs.io/en/stable/reference/generated/optuna.integration.BoTorchSampler.html#optuna.integration.BoTorchSampler) have the same interfaces regarding constrained optimization.

```python
optuna.visualization.plot_pareto_front(study, *, ..., constraints_func: Optional[Callable[[FrozenTrial], Sequence[float]]] = None)
```


## Alternatives (optional)

<!-- Please write a clear and concise description of any alternative solutions or features you've considered. -->

## Additional context (optional)

<!-- Please add any other context or screenshots about the feature request here. -->



Discussion:
This issue has not seen any recent activity.
I started to work on this issue. thanks!

Resolving patch:
diff --git a/optuna/visualization/matplotlib/_pareto_front.py b/optuna/visualization/matplotlib/_pareto_front.py
--- a/optuna/visualization/matplotlib/_pareto_front.py
+++ b/optuna/visualization/matplotlib/_pareto_front.py
@@ -23,6 +23,7 @@ def plot_pareto_front(
     target_names: Optional[List[str]] = None,
     include_dominated_trials: bool = True,
     axis_order: Optional[List[int]] = None,
+    constraints_func: Optional[Callable[[FrozenTrial], Sequence[float]]] = None,
     targets: Optional[Callable[[FrozenTrial], Sequence[float]]] = None,
 ) -> "Axes":
     """Plot the Pareto front of a study.
@@ -72,6 +73,17 @@ def objective(trial):
                 Deprecated in v3.0.0. This feature will be removed in the future. The removal of
                 this feature is currently scheduled for v5.0.0, but this schedule is subject to
                 change. See https://github.com/optuna/optuna/releases/tag/v3.0.0.
+        constraints_func:
+            An optional function that computes the objective constraints. It must take a
+            :class:`~optuna.trial.FrozenTrial` and return the constraints. The return value must
+            be a sequence of :obj:`float` s. A value strictly larger than 0 means that a
+            constraint is violated. A value equal to or smaller than 0 is considered feasible.
+            This specification is the same as in, for example,
+            :class:`~optuna.integration.NSGAIISampler`.
+
+            If given, trials are classified into three categories: feasible and best, feasible but
+            non-best, and infeasible. Categories are shown in different colors. Here, whether a
+            trial is best (on Pareto front) or not is determined ignoring all infeasible trials.
         targets:
             A function that returns a tuple of target values to display.
             The argument to this function is :class:`~optuna.trial.FrozenTrial`.
@@ -91,7 +103,7 @@ def objective(trial):
     _imports.check()
 
     info = _get_pareto_front_info(
-        study, target_names, include_dominated_trials, axis_order, None, targets
+        study, target_names, include_dominated_trials, axis_order, constraints_func, targets
     )
 
     if info.n_targets == 2:
@@ -112,6 +124,16 @@ def _get_pareto_front_2d(info: _ParetoFrontInfo) -> "Axes":
     ax.set_xlabel(info.target_names[info.axis_order[0]])
     ax.set_ylabel(info.target_names[info.axis_order[1]])
 
+    if (
+        info.infeasible_trials_with_values is not None
+        and len(info.infeasible_trials_with_values) > 0
+    ):
+        ax.scatter(
+            x=[values[info.axis_order[0]] for _, values in info.infeasible_trials_with_values],
+            y=[values[info.axis_order[1]] for _, values in info.infeasible_trials_with_values],
+            color="#cccccc",
+            label="Infeasible Trial",
+        )
     if info.non_best_trials_with_values is not None and len(info.non_best_trials_with_values) > 0:
         ax.scatter(
             x=[values[info.axis_order[0]] for _, values in info.non_best_trials_with_values],
@@ -145,6 +167,18 @@ def _get_pareto_front_3d(info: _ParetoFrontInfo) -> "Axes":
     ax.set_ylabel(info.target_names[info.axis_order[1]])
     ax.set_zlabel(info.target_names[info.axis_order[2]])
 
+    if (
+        info.infeasible_trials_with_values is not None
+        and len(info.infeasible_trials_with_values) > 0
+    ):
+        ax.scatter(
+            xs=[values[info.axis_order[0]] for _, values in info.infeasible_trials_with_values],
+            ys=[values[info.axis_order[1]] for _, values in info.infeasible_trials_with_values],
+            zs=[values[info.axis_order[2]] for _, values in info.infeasible_trials_with_values],
+            color="#cccccc",
+            label="Infeasible Trial",
+        )
+
     if info.non_best_trials_with_values is not None and len(info.non_best_trials_with_values) > 0:
         ax.scatter(
             xs=[values[info.axis_order[0]] for _, values in info.non_best_trials_with_values],


Test patch:
diff --git a/tests/visualization_tests/matplotlib_tests/test_pareto_front.py b/tests/visualization_tests/matplotlib_tests/test_pareto_front.py
--- a/tests/visualization_tests/matplotlib_tests/test_pareto_front.py
+++ b/tests/visualization_tests/matplotlib_tests/test_pareto_front.py
@@ -6,9 +6,10 @@
 from typing import Sequence
 from typing import Union
 
-from matplotlib.collections import PathCollection
+from matplotlib.axes._axes import Axes
 import matplotlib.pyplot as plt
 import numpy as np
+import numpy.ma.testutils
 import pytest
 
 import optuna
@@ -24,13 +25,41 @@ def allclose_as_set(
     return np.allclose(sorted(p1), sorted(p2))
 
 
+def _check_data(figure: Axes, axis: str, expected: Sequence[int]) -> None:
+    """Compare `figure` against `expected`.
+
+    Concatenate `data` in `figure` in reverse order, pick the desired `axis`, and compare with
+    the `expected` result.
+
+    Args:
+        figure: A figure.
+        axis: The axis to be checked.
+        expected: The expected result.
+    """
+    axis_map = {"x": 0, "y": 1, "z": 2}
+    n_data = len(figure.collections)
+    actual = tuple(
+        itertools.chain(
+            *list(
+                map(
+                    lambda i: figure.collections[i].get_offsets()[:, axis_map[axis]],
+                    reversed(range(n_data)),
+                )
+            )
+        )
+    )
+    numpy.ma.testutils.assert_array_equal(actual, expected)
+
+
 @pytest.mark.filterwarnings("ignore::optuna.exceptions.ExperimentalWarning")
 @pytest.mark.parametrize("include_dominated_trials", [False, True])
 @pytest.mark.parametrize("axis_order", [None, [0, 1], [1, 0]])
+@pytest.mark.parametrize("use_constraints_func", [False, True])
 @pytest.mark.parametrize("targets", [None, lambda t: (t.values[0], t.values[1])])
 def test_plot_pareto_front_2d(
     include_dominated_trials: bool,
     axis_order: Optional[List[int]],
+    use_constraints_func: bool,
     targets: Optional[Callable[[FrozenTrial], Sequence[float]]],
 ) -> None:
     if axis_order is not None and targets is not None:
@@ -46,36 +75,52 @@ def test_plot_pareto_front_2d(
     assert len(figure.get_lines()) == 0
     plt.savefig(BytesIO())
 
-    # Test with three trials.
+    # Test with four trials.
+    study.enqueue_trial({"x": 1, "y": 2})
     study.enqueue_trial({"x": 1, "y": 1})
+    study.enqueue_trial({"x": 0, "y": 2})
     study.enqueue_trial({"x": 1, "y": 0})
-    study.enqueue_trial({"x": 0, "y": 1})
-    study.optimize(lambda t: [t.suggest_int("x", 0, 1), t.suggest_int("y", 0, 1)], n_trials=3)
+    study.optimize(lambda t: [t.suggest_int("x", 0, 2), t.suggest_int("y", 0, 2)], n_trials=4)
+
+    constraints_func: Optional[Callable[[FrozenTrial], Sequence[float]]]
+    if use_constraints_func:
+        # (x, y) = (1, 0) is infeasible; others are feasible.
+        def constraints_func(t: FrozenTrial) -> Sequence[float]:
+            return [1.0] if t.params["x"] == 1 and t.params["y"] == 0 else [-1.0]
+
+    else:
+        constraints_func = None
 
     figure = plot_pareto_front(
         study=study,
         include_dominated_trials=include_dominated_trials,
         axis_order=axis_order,
+        constraints_func=constraints_func,
         targets=targets,
     )
     assert len(figure.get_lines()) == 0
 
-    if axis_order is not None:
-        pareto_front_points = np.array([[1.0, 0.0], [0.0, 1.0]])[:, axis_order]
+    actual_axis_order = axis_order or [0, 1]
+    if use_constraints_func:
+        if include_dominated_trials:
+            # The enqueue order of trial is: infeasible, feasible non-best, then feasible best.
+            assert len(figure.collections) == 3
+            data = [(1, 0, 1, 1), (1, 2, 2, 0)]  # type: ignore
+        else:
+            # The enqueue order of trial is: infeasible, feasible.
+            assert len(figure.collections) == 2
+            data = [(1, 0, 1), (1, 2, 0)]  # type: ignore
     else:
-        pareto_front_points = np.array([[1.0, 0.0], [0.0, 1.0]])
-    assert pareto_front_points.shape == (2, 2)
-
-    path_offsets = list(map(lambda pc: pc.get_offsets(), figure.findobj(PathCollection)))
-    exists_pareto_front = any(
-        map(lambda po: allclose_as_set(po, pareto_front_points), path_offsets)
-    )
-    exists_dominated_trials = any(
-        map(lambda po: allclose_as_set(po, np.array([[1.0, 1.0]])), path_offsets)
-    )
-    assert exists_pareto_front
-    if include_dominated_trials:
-        assert exists_dominated_trials
+        if include_dominated_trials:
+            # The last elements come from dominated trial that is enqueued firstly.
+            assert len(figure.collections) == 2
+            data = [(0, 1, 1, 1), (2, 0, 2, 1)]  # type: ignore
+        else:
+            assert len(figure.collections) == 1
+            data = [(0, 1), (2, 0)]  # type: ignore
+
+    _check_data(figure, "x", data[actual_axis_order[0]])
+    _check_data(figure, "y", data[actual_axis_order[1]])
     plt.savefig(BytesIO())
 
     # Test with `target_names` argument.
@@ -86,6 +131,7 @@ def test_plot_pareto_front_2d(
             study=study,
             target_names=[],
             include_dominated_trials=include_dominated_trials,
+            constraints_func=constraints_func,
             targets=targets,
         )
 
@@ -94,6 +140,7 @@ def test_plot_pareto_front_2d(
             study=study,
             target_names=["Foo"],
             include_dominated_trials=include_dominated_trials,
+            constraints_func=constraints_func,
             targets=targets,
         )
 
@@ -103,6 +150,7 @@ def test_plot_pareto_front_2d(
             target_names=["Foo", "Bar", "Baz"],
             include_dominated_trials=include_dominated_trials,
             axis_order=axis_order,
+            constraints_func=constraints_func,
             targets=targets,
         )
 
@@ -112,6 +160,7 @@ def test_plot_pareto_front_2d(
         target_names=target_names,
         include_dominated_trials=include_dominated_trials,
         axis_order=axis_order,
+        constraints_func=constraints_func,
         targets=targets,
     )
     assert len(figure.get_lines()) == 0
@@ -123,34 +172,18 @@ def test_plot_pareto_front_2d(
         assert figure.get_xlabel() == target_names[axis_order[0]]
         assert figure.get_ylabel() == target_names[axis_order[1]]
 
-    if axis_order is not None:
-        pareto_front_points = np.array([[1.0, 0.0], [0.0, 1.0]])[:, axis_order]
-    else:
-        pareto_front_points = np.array([[1.0, 0.0], [0.0, 1.0]])
-    assert pareto_front_points.shape == (2, 2)
-
-    path_offsets = list(map(lambda pc: pc.get_offsets(), figure.findobj(PathCollection)))
-    exists_pareto_front = any(
-        map(lambda po: allclose_as_set(po, pareto_front_points), path_offsets)
-    )
-    exists_dominated_trials = any(
-        map(lambda po: allclose_as_set(po, np.array([[1.0, 1.0]])), path_offsets)
-    )
-    assert exists_pareto_front
-    if include_dominated_trials:
-        assert exists_dominated_trials
-    plt.savefig(BytesIO())
-
 
 @pytest.mark.filterwarnings("ignore::optuna.exceptions.ExperimentalWarning")
 @pytest.mark.parametrize("include_dominated_trials", [False, True])
 @pytest.mark.parametrize(
     "axis_order", [None] + list(itertools.permutations(range(3), 3))  # type: ignore
 )
+@pytest.mark.parametrize("use_constraints_func", [False, True])
 @pytest.mark.parametrize("targets", [None, lambda t: (t.values[0], t.values[1], t.values[2])])
 def test_plot_pareto_front_3d(
     include_dominated_trials: bool,
     axis_order: Optional[List[int]],
+    use_constraints_func: bool,
     targets: Optional[Callable[[FrozenTrial], Sequence[float]]],
 ) -> None:
     if axis_order is not None and targets is not None:
@@ -166,36 +199,60 @@ def test_plot_pareto_front_3d(
     plt.savefig(BytesIO())
 
     # Test with three trials.
+    study.enqueue_trial({"x": 1, "y": 1, "z": 2})
     study.enqueue_trial({"x": 1, "y": 1, "z": 1})
-    study.enqueue_trial({"x": 1, "y": 0, "z": 1})
+    study.enqueue_trial({"x": 1, "y": 0, "z": 2})
     study.enqueue_trial({"x": 1, "y": 1, "z": 0})
     study.optimize(
-        lambda t: [t.suggest_int("x", 0, 1), t.suggest_int("y", 0, 1), t.suggest_int("z", 0, 1)],
-        n_trials=3,
+        lambda t: [t.suggest_int("x", 0, 1), t.suggest_int("y", 0, 2), t.suggest_int("z", 0, 2)],
+        n_trials=4,
     )
 
+    constraints_func: Optional[Callable[[FrozenTrial], Sequence[float]]]
+    if use_constraints_func:
+        # (x, y, z) = (1, 1, 0) is infeasible; others are feasible.
+        def constraints_func(t: FrozenTrial) -> Sequence[float]:
+            return (
+                [1.0]
+                if t.params["x"] == 1 and t.params["y"] == 1 and t.params["z"] == 0
+                else [-1.0]
+            )
+
+    else:
+        constraints_func = None
+
     figure = plot_pareto_front(
         study=study,
         include_dominated_trials=include_dominated_trials,
         axis_order=axis_order,
+        constraints_func=constraints_func,
         targets=targets,
     )
+    actual_axis_order = axis_order or [0, 1, 2]
     assert len(figure.get_lines()) == 0
 
-    if axis_order is not None:
-        pareto_front_points = np.array([[1.0, 0.0, 1.0], [1.0, 1.0, 0.0]])[:, axis_order][:, 0:2]
+    if use_constraints_func:
+        if include_dominated_trials:
+            # The enqueue order of trial is: infeasible, feasible non-best, then feasible best.
+            assert len(figure.collections) == 3
+            data = [(1, 1, 1, 1), (1, 0, 1, 1), (1, 2, 2, 0)]  # type: ignore
+        else:
+            # The enqueue order of trial is: infeasible, feasible.
+            assert len(figure.collections) == 2
+            data = [(1, 1, 1), (1, 0, 1), (1, 2, 0)]  # type: ignore
     else:
-        pareto_front_points = np.array([[1.0, 0.0, 1.0], [1.0, 1.0, 0.0]])[:, 0:2]
-    assert pareto_front_points.shape == (2, 2)
-
-    path_offsets = list(map(lambda pc: pc.get_offsets(), figure.findobj(PathCollection)))
-    exists_pareto_front = any(
-        map(lambda po: allclose_as_set(po, pareto_front_points), path_offsets)
-    )
-    exists_dominated_trials = any(map(lambda po: allclose_as_set(po, [[1.0, 1.0]]), path_offsets))
-    assert exists_pareto_front
-    if include_dominated_trials:
-        assert exists_dominated_trials
+        if include_dominated_trials:
+            # The last elements come from dominated trial that is enqueued firstly.
+            assert len(figure.collections) == 2
+            data = [(1, 1, 1, 1), (0, 1, 1, 1), (2, 0, 2, 1)]  # type: ignore
+        else:
+            assert len(figure.collections) == 1
+            data = [(1, 1), (0, 1), (2, 0)]  # type: ignore
+
+    _check_data(figure, "x", data[actual_axis_order[0]])
+    _check_data(figure, "y", data[actual_axis_order[1]])
+    # TODO(fukatani): check z data.
+    # _check_data(figure, "z", data[actual_axis_order[2]])
     plt.savefig(BytesIO())
 
     # Test with `target_names` argument.
@@ -207,6 +264,7 @@ def test_plot_pareto_front_3d(
             target_names=[],
             include_dominated_trials=include_dominated_trials,
             axis_order=axis_order,
+            constraints_func=constraints_func,
             targets=targets,
         )
 
@@ -216,6 +274,7 @@ def test_plot_pareto_front_3d(
             target_names=["Foo"],
             include_dominated_trials=include_dominated_trials,
             axis_order=axis_order,
+            constraints_func=constraints_func,
             targets=targets,
         )
 
@@ -225,6 +284,7 @@ def test_plot_pareto_front_3d(
             target_names=["Foo", "Bar"],
             include_dominated_trials=include_dominated_trials,
             axis_order=axis_order,
+            constraints_func=constraints_func,
             targets=targets,
         )
 
@@ -234,12 +294,17 @@ def test_plot_pareto_front_3d(
             target_names=["Foo", "Bar", "Baz", "Qux"],
             include_dominated_trials=include_dominated_trials,
             axis_order=axis_order,
+            constraints_func=constraints_func,
             targets=targets,
         )
 
     target_names = ["Foo", "Bar", "Baz"]
     figure = plot_pareto_front(
-        study=study, target_names=target_names, axis_order=axis_order, targets=targets
+        study=study,
+        target_names=target_names,
+        axis_order=axis_order,
+        targets=targets,
+        constraints_func=constraints_func,
     )
 
     assert len(figure.get_lines()) == 0
@@ -253,26 +318,14 @@ def test_plot_pareto_front_3d(
         assert figure.get_ylabel() == target_names[axis_order[1]]
         assert figure.get_zlabel() == target_names[axis_order[2]]
 
-    if axis_order is not None:
-        pareto_front_points = np.array([[1.0, 0.0, 1.0], [1.0, 1.0, 0.0]])[:, axis_order][:, 0:2]
-    else:
-        pareto_front_points = np.array([[1.0, 0.0, 1.0], [1.0, 1.0, 0.0]])[:, 0:2]
-    assert pareto_front_points.shape == (2, 2)
-
-    path_offsets = list(map(lambda pc: pc.get_offsets(), figure.findobj(PathCollection)))
-    exists_pareto_front = any(
-        map(lambda po: allclose_as_set(po, pareto_front_points), path_offsets)
-    )
-    exists_dominated_trials = any(map(lambda po: allclose_as_set(po, [[1.0, 1.0]]), path_offsets))
-    assert exists_pareto_front
-    if include_dominated_trials:
-        assert exists_dominated_trials
-    plt.savefig(BytesIO())
-
 
 @pytest.mark.filterwarnings("ignore::optuna.exceptions.ExperimentalWarning")
 @pytest.mark.parametrize("include_dominated_trials", [False, True])
-def test_plot_pareto_front_unsupported_dimensions(include_dominated_trials: bool) -> None:
+@pytest.mark.parametrize("use_constraints_func", [False, True])
+def test_plot_pareto_front_unsupported_dimensions(
+    include_dominated_trials: bool, use_constraints_func: bool
+) -> None:
+    constraints_func = (lambda x: [-1.0]) if use_constraints_func else None
 
     error_message = (
         "`plot_pareto_front` function only supports 2 or 3 objective"
@@ -284,28 +337,42 @@ def test_plot_pareto_front_unsupported_dimensions(include_dominated_trials: bool
     with pytest.raises(ValueError, match=error_message):
         study = optuna.create_study(directions=["minimize"])
         study.optimize(lambda t: [0], n_trials=1)
-        plot_pareto_front(study=study, include_dominated_trials=include_dominated_trials)
+        plot_pareto_front(
+            study=study,
+            include_dominated_trials=include_dominated_trials,
+            constraints_func=constraints_func,
+        )
 
     with pytest.raises(ValueError, match=error_message):
         study = optuna.create_study(direction="minimize")
         study.optimize(lambda t: [0], n_trials=1)
-        plot_pareto_front(study=study, include_dominated_trials=include_dominated_trials)
+        plot_pareto_front(
+            study=study,
+            include_dominated_trials=include_dominated_trials,
+            constraints_func=constraints_func,
+        )
 
     # Unsupported: n_objectives == 4.
     with pytest.raises(ValueError, match=error_message):
         study = optuna.create_study(directions=["minimize", "minimize", "minimize", "minimize"])
         study.optimize(lambda t: [0, 0, 0, 0], n_trials=1)
-        plot_pareto_front(study=study, include_dominated_trials=include_dominated_trials)
+        plot_pareto_front(
+            study=study,
+            include_dominated_trials=include_dominated_trials,
+            constraints_func=constraints_func,
+        )
 
 
 @pytest.mark.filterwarnings("ignore::optuna.exceptions.ExperimentalWarning")
 @pytest.mark.parametrize("dimension", [2, 3])
 @pytest.mark.parametrize("include_dominated_trials", [False, True])
+@pytest.mark.parametrize("use_constraints_func", [False, True])
 def test_plot_pareto_front_invalid_axis_order(
-    dimension: int, include_dominated_trials: bool
+    dimension: int, include_dominated_trials: bool, use_constraints_func: bool
 ) -> None:
     study = optuna.create_study(directions=["minimize"] * dimension)
     study.optimize(lambda t: [0] * dimension, n_trials=1)
+    constraints_func = (lambda x: [-1.0]) if use_constraints_func else None
 
     # Invalid: len(axis_order) != dimension
     with pytest.raises(ValueError):
@@ -315,6 +382,7 @@ def test_plot_pareto_front_invalid_axis_order(
             study=study,
             include_dominated_trials=include_dominated_trials,
             axis_order=invalid_axis_order,
+            constraints_func=constraints_func,
         )
 
     # Invalid: np.unique(axis_order).size != dimension
@@ -326,6 +394,7 @@ def test_plot_pareto_front_invalid_axis_order(
             study=study,
             include_dominated_trials=include_dominated_trials,
             axis_order=invalid_axis_order,
+            constraints_func=constraints_func,
         )
 
     # Invalid: max(axis_order) > (dimension - 1)
@@ -337,6 +406,7 @@ def test_plot_pareto_front_invalid_axis_order(
             study=study,
             include_dominated_trials=include_dominated_trials,
             axis_order=invalid_axis_order,
+            constraints_func=constraints_func,
         )
 
     # Invalid: min(axis_order) < 0
@@ -350,6 +420,7 @@ def test_plot_pareto_front_invalid_axis_order(
             study=study,
             include_dominated_trials=include_dominated_trials,
             axis_order=invalid_axis_order,
+            constraints_func=constraints_func,
         )