Skip to content

Add support for constrained multi-objective optimization in GPSampler#6224

Merged
nabenabe0928 merged 10 commits intooptuna:masterfrom
kAIto47802:add-support-for-constrained-multiobjective-optimization-in-gpsampler
Aug 1, 2025
Merged

Add support for constrained multi-objective optimization in GPSampler#6224
nabenabe0928 merged 10 commits intooptuna:masterfrom
kAIto47802:add-support-for-constrained-multiobjective-optimization-in-gpsampler

Conversation

@kAIto47802
Copy link
Copy Markdown
Collaborator

@kAIto47802 kAIto47802 commented Jul 30, 2025

Motivation

This PR adds support for constrained multi-objective optimization in GPSampler, extending the functionality introduced in:

which added support for constrained optimization and multi-objective optimization in GPSampler, respectively.

Here, we use the ConstrainedLogEHVI introduced in:

Description of the changes

  • Add a block to the sample_relative method in GPSampler to support constrained multi-objective optimization.

Benchmarks

I conduct benchmarks to evaluate the effectiveness of this new feature.

Benchmarking Setup

Benchmarking Problem. I use the C2-DTLZ2 problem 1, available in the C-DTLZ problem collection on OptunaHub. I set the number of objectives to two and the number of variables to three. See the original paper 1 and the OptunaHub document for more details.

Implementation Details. I set the number of trials to 300. Also, I use deterministic_objective=True for GPSampler, since the C2-DTLZ problem is deterministic. The benchmarking code and the visualization code I used is as follows:

The benchmarking code

benchmark.py

from argparse import ArgumentParser, Namespace
from datetime import datetime
from pathlib import Path
from typing import cast

import numpy as np
import optunahub

import optuna


def _extract_elapsed_time(study: optuna.study.Study) -> list[float]:
    return [
        (
            cast(datetime, t.datetime_complete) - cast(datetime, study.trials[0].datetime_start)
        ).total_seconds()
        for t in study.trials
    ]


def _extract_objective_value(study: optuna.study.Study) -> list[float | None]:
    return [t.values for t in study.trials]


def experiment_once(
    problem: optunahub.benchmarks.BaseProblem,
    sampler_name: str,
    n_trials: int,
    seed: int,
    name: str,
) -> tuple[list[float], list[float | None]]:
    sampler = {
        "gp": optuna.samplers.GPSampler(
            seed=seed, constraints_func=problem.constraints_func, deterministic_objective=True
        ),
        "gp_wo_constraints": optuna.samplers.GPSampler(seed=seed, deterministic_objective=True),
        "tpe": optuna.samplers.TPESampler(seed=seed, constraints_func=problem.constraints_func),
        "tpe_wo_constraints": optuna.samplers.TPESampler(seed=seed),
        "nsgaii": optuna.samplers.NSGAIISampler(
            seed=seed, constraints_func=problem.constraints_func
        ),
    }[sampler_name]
    study = optuna.create_study(
        study_name=f"{name}_seed{seed}",
        sampler=sampler,
        directions=problem.directions,
        storage="sqlite:///results/results.db",
    )
    study.optimize(problem, n_trials=n_trials)
    times = _extract_elapsed_time(study)
    values = _extract_objective_value(study)
    return times, values


def main(args: Namespace) -> None:
    Path("results").mkdir(exist_ok=True)
    cdtlz = optunahub.load_local_module(
        "dtlz_constrained", registry_root="../optunahub-registry/package/benchmarks"
    )
    problem = cdtlz.Problem(
        n_objectives=args.n_objectives,
        dimension=args.dimension,
        function_id=args.function_id,
        constraint_type=args.constraint_type,
    )

    name = f"cdtlz_C{args.constraint_type}-DTLZ{args.function_id}_n_objectives{args.n_objectives}_dim{args.dimension}_trial{args.n_trials}_{args.sampler}"
    times, values = zip(
        *[
            experiment_once(problem, args.sampler, args.n_trials, seed, name)
            for seed in range(42, 42 + args.n_seeds)
        ]
    )
    np.savez(
        f"results/{name}.npz",
        times=np.array(times),
        values=np.array(values),
        dimension=args.dimension,
        n_objectives=args.n_objectives,
        constraint_type=args.constraint_type,
        function_id=args.function_id,
    )


if __name__ == "__main__":
    parser = ArgumentParser()
    parser.add_argument(
        "--sampler",
        type=str,
        default="gp",
        help="Sampler to use for the optimization (default: GP).",
    )
    parser.add_argument(
        "--n_objectives",
        type=int,
        default=2,
        help="Number of objectives for the problem.",
    )
    parser.add_argument(
        "--dimension",
        type=int,
        default=3,
        help="Dimension of the problem (number of variables).",
    )
    parser.add_argument(
        "--constraint_type",
        type=int,
        default=1,
        help="Constraint type for the problem (1 or 2).",
    )
    parser.add_argument(
        "--function_id",
        type=int,
        default=2,
        help="Function ID for the DTLZ problem (1-7).",
    )
    parser.add_argument(
        "--n_trials",
        type=int,
        default=300,
        help="Number of trials for the optimization.",
    )
    parser.add_argument(
        "--n_seeds",
        type=int,
        default=5,
        help="Number of random seeds for the optimization.",
    )
    args = parser.parse_args()

    main(args)

benchmark.sh

#!/bin/bash

samplers=("gp" "gp_wo_constraints" "tpe" "nsgaii")

for sampler in "${samplers[@]}"; do
    python benchmark.py --constraint_type 2 --function_id 2 --sampler $sampler
done
The visualization code for the Pareto front

plot_pareto_front.py

from argparse import ArgumentParser, Namespace

import matplotlib.pyplot as plt
import optunahub
from matplotlib import font_manager
from matplotlib.figure import Figure

import optuna
import optuna.visualization.matplotlib
from optuna.visualization._pareto_front import _ParetoFrontInfo
from optuna.visualization.matplotlib import plot_pareto_front

fp = font_manager.FontProperties(fname="/usr/share/fonts/TTF/Times.TTF")


def _get_pareto_front_2d_patched(
    info: _ParetoFrontInfo,
    xlim: tuple[float | None, float | None] | None = None,
    ylim: tuple[float | None, float | None] | None = None,
    use_sf: bool = True,
) -> Figure:
    fig, ax = plt.subplots()

    ax.set_xlabel(
        info.target_names[info.axis_order[0]], fontsize=13, fontproperties=fp if use_sf else None
    )
    ax.set_ylabel(
        info.target_names[info.axis_order[1]], fontsize=13, fontproperties=fp if use_sf else None
    )

    if 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="#2B2B2B",
            facecolors="#6E6E6E",
            alpha=0.7,
            label="Infeasible Trial",
        )
    if 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],
            y=[values[info.axis_order[1]] for _, values in info.non_best_trials_with_values],
            color="#963269",
            facecolors="#CC79A7",
            alpha=0.7,
            label="Feasible Trial",
        )
    if len(info.best_trials_with_values) > 0:
        ax.scatter(
            x=[values[info.axis_order[0]] for _, values in info.best_trials_with_values],
            y=[values[info.axis_order[1]] for _, values in info.best_trials_with_values],
            color="#1348AC",
            facecolors="#0072B2",
            alpha=0.7,
            label="Best Trial",
        )

    if info.non_best_trials_with_values is not None and ax.has_data():
        ax.legend(
            handlelength=0.8,
            handletextpad=0.4,
            prop=(
                font_manager.FontProperties(fname="/usr/share/fonts/TTF/Times.TTF", size=11.5)
                if use_sf
                else None
            ),
        )

    ax.grid(which="major", color="gray", linestyle="--", linewidth=0.5)
    if use_sf:
        for lbl in ax.get_xticklabels() + ax.get_yticklabels():
            lbl.set_fontproperties(fp)
    ax.tick_params(labelsize=12)

    if xlim is not None:
        ax.set_xlim(*xlim)
    if ylim is not None:
        ax.set_ylim(*ylim)

    return fig


def main(args: Namespace) -> None:
    optuna.visualization.matplotlib._pareto_front._get_pareto_front_2d = (
        lambda info: _get_pareto_front_2d_patched(
            info, xlim=(None, 1.56), ylim=(None, 1.56), use_sf=args.use_sf
        )
    )

    cdtlz = optunahub.load_local_module(
        "dtlz_constrained", registry_root="../optunahub-registry/package/benchmarks"
    )
    problem = cdtlz.Problem(
        n_objectives=args.n_objectives,
        dimension=args.dimension,
        function_id=args.function_id,
        constraint_type=args.constraint_type,
    )

    name = f"cdtlz_C{args.constraint_type}-DTLZ{args.function_id}_n_objectives{args.n_objectives}_dim{args.dimension}_trial{args.n_trials}_{args.sampler}_seed{args.seed}"
    study = optuna.load_study(
        study_name=name,
        storage="sqlite:///results/results.db",
    )
    fig = plot_pareto_front(study, constraints_func=problem.constraints_func)
    fig.savefig(
        f"results/{name}_pareto_front{'_sf' if args.use_sf else ''}.{args.file_format}",
        bbox_inches="tight",
        dpi=300,
    )


if __name__ == "__main__":
    parser = ArgumentParser()
    parser.add_argument(
        "--sampler",
        type=str,
        default="gp",
        help="Sampler to use for the optimization (default: GP).",
    )
    parser.add_argument(
        "--n_objectives",
        type=int,
        default=2,
        help="Number of objectives for the problem.",
    )
    parser.add_argument(
        "--dimension",
        type=int,
        default=3,
        help="Dimension of the problem (number of variables).",
    )
    parser.add_argument(
        "--constraint_type",
        type=int,
        default=1,
        help="Constraint type for the problem (1 or 2).",
    )
    parser.add_argument(
        "--function_id",
        type=int,
        default=2,
        help="Function ID for the DTLZ problem (1-7).",
    )
    parser.add_argument(
        "--n_trials",
        type=int,
        default=300,
        help="Number of trials for the optimization.",
    )
    parser.add_argument(
        "--seed",
        type=int,
        default=42,
        help="Number of random seeds for the optimization.",
    )
    parser.add_argument(
        "--use_sf",
        type=bool,
        default=True,
        help="Use sans-serif font for the plot.",
    )
    parser.add_argument(
        "--file_format",
        type=str,
        default="pdf",
        choices=["png", "pdf"],
        help="File format for the output plot (e.g., png, pdf).",
    )
    args = parser.parse_args()

    main(args)

place_figures.tex

\documentclass{article}

\usepackage{graphicx}
\usepackage{subcaption}
\usepackage{geometry}
\geometry{paperwidth=180mm, paperheight=150mm, left=3mm, right=0mm, top=0mm, bottom=0mm}

\begin{document}

\begin{figure}[h]
    \centering
    \begin{minipage}[b]{0.49\linewidth}
        \centering
        \hspace{-5mm}\includegraphics[width=\linewidth]{results/cdtlz_C2-DTLZ2_n_objectives2_dim3_trial300_gp_seed42_pareto_front_sf.pdf}
        \vspace{-2mm}
        \subcaption{GPSampler w/ constraints}
    \end{minipage}
    \begin{minipage}[b]{0.49\linewidth}
        \centering
        \hspace{-5mm}\includegraphics[width=\linewidth]{results/cdtlz_C2-DTLZ2_n_objectives2_dim3_trial300_gp_wo_constraints_seed42_pareto_front_sf.pdf}
        \vspace{-2mm}
        \subcaption{GPSampler w/o constraints}
    \end{minipage}

    \vspace{3mm}
    \begin{minipage}[b]{0.49\linewidth}
        \centering
        \hspace{-5mm}\includegraphics[width=\linewidth]{results/cdtlz_C2-DTLZ2_n_objectives2_dim3_trial300_tpe_seed42_pareto_front_sf.pdf}
        \vspace{-2mm}
        \subcaption{TPESampler w/ constraints}
    \end{minipage}
    \begin{minipage}[b]{0.49\linewidth}
        \centering
        \hspace{-5mm}\includegraphics[width=\linewidth]{results/cdtlz_C2-DTLZ2_n_objectives2_dim3_trial300_nsgaii_seed42_pareto_front_sf.pdf}
        \vspace{-2mm}
        \subcaption{NSGAIISampler w/ constraints}
    \end{minipage}
\end{figure}

\end{document}

plot_pareto_front.sh

#!/bin/bash

samplers=("gp" "gp_wo_constraints" "tpe" "nsgaii")

for sampler in "${samplers[@]}"; do
    python plot_pareto_front.py --constraint_type 2 --function_id 2 --sampler $sampler
done

pdflatex place_figure.tex

convert -density 600 place_figure.pdf -quality 300 -background white -alpha remove -alpha off results/parato_front.png
The visualization code for the hypervolume history
from argparse import ArgumentParser, Namespace

import matplotlib.pyplot as plt
import numpy as np
from matplotlib import font_manager
from matplotlib.figure import Figure

import optuna
from optuna.visualization._hypervolume_history import (
    _get_hypervolume_history_info,
    _HypervolumeHistoryInfo,
)

fp = font_manager.FontProperties(fname="/usr/share/fonts/TTF/Times.TTF")


def plot_results(
    data: dict[str, list[_HypervolumeHistoryInfo]],
    names: dict[str, str],
    colors: dict[str, str],
    markers: dict[str, str],
    marker_sizes: dict[str, float],
    legend_order: list[str],
    ylabel: str,
    use_sf: bool = True,
    trial_min: int | None = None,
    xlim: tuple[float, float] | None = None,
    ylim: tuple[float, float] | None = None,
    figsize: tuple[float, float] | None = None,
) -> Figure:
    fig, ax = plt.subplots(figsize=figsize)
    lines = {}
    names_list = {}
    for name, d in data.items():
        values = np.array([info.values for info in d])
        mean = np.mean(values, axis=0)
        std = np.std(values, axis=0) / np.sqrt(values.shape[0])
        dx = d[0].trial_numbers
        (line,) = ax.plot(
            dx[trial_min:],
            mean[trial_min:],
            colors[name],
            label=names[name],
            marker=markers[name],
            markersize=marker_sizes[name] * 1.2,
            markevery=8,
        )
        lines[name] = line
        names_list[name] = names[name]
        ax.fill_between(
            dx[trial_min:],
            (mean - std)[trial_min:],
            (mean + std)[trial_min:],
            alpha=0.2,
            color=colors[name],
        )
    ax.legend(
        handles=[lines[name] for name in legend_order],
        labels=[names_list[name] for name in legend_order],
        loc="lower right",
        fontsize=12,
        prop=(
            font_manager.FontProperties(fname="/usr/share/fonts/TTF/Times.TTF", size=12)
            if use_sf
            else None
        ),
    )
    ax.set_xlabel("Number of Trials", fontsize=13, fontproperties=fp if use_sf else None)
    ax.set_ylabel(ylabel, fontsize=13, fontproperties=fp if use_sf else None)

    ax.grid(which="major", color="gray", linestyle="--", linewidth=0.5)
    if use_sf:
        for lbl in ax.get_xticklabels() + ax.get_yticklabels():
            lbl.set_fontproperties(fp)
    ax.tick_params(labelsize=12)

    if xlim is not None:
        ax.set_xlim(*xlim)
    if ylim is not None:
        ax.set_ylim(*ylim)

    return fig


def main(args: Namespace) -> None:
    sampler_names = {
        "gp": "GPSampler",
        "tpe": "TPESampler",
        "nsgaii": "NSGAIISampler",
    }
    colors = {
        "gp": "#0072B2",
        "tpe": "#CC79A7",
        "nsgaii": "#E69F00",
    }
    markers = {
        "gp": "*",
        "tpe": "o",
        "nsgaii": "D",
    }
    marker_sizes = {
        "gp": 8.0,
        "tpe": 5.1,
        "nsgaii": 4.2,
    }
    legend_order = ["gp", "tpe", "nsgaii"]

    name = f"cdtlz_C{args.constraint_type}-DTLZ{args.function_id}_n_objectives{args.n_objectives}_dim{args.dimension}_trial{args.n_trials}"
    data = {
        sampler_name: [
            _get_hypervolume_history_info(
                optuna.load_study(
                    study_name=f"{name}_{sampler_name}_seed{seed}",
                    storage="sqlite:///results/results.db",
                ),
                np.array([2.0] * args.n_objectives, dtype=float),
            )
            for seed in range(42, 42 + args.n_seeds)
        ]
        for sampler_name in args.samplers
    }

    fig = plot_results(
        data,
        names=sampler_names,
        colors=colors,
        markers=markers,
        marker_sizes=marker_sizes,
        legend_order=legend_order,
        ylabel="Hypervolume",
        use_sf=args.use_sf,
        xlim=(6, args.n_trials + 4),
        ylim=(2.56, 3.23),
        trial_min=10,
        figsize=(7.5, 4),
    )
    fig.savefig(
        f"results/{name}_hypervolume_history{'_sf' if args.use_sf else ''}.png",
        bbox_inches="tight",
        dpi=300,
    )


if __name__ == "__main__":
    parser = ArgumentParser()
    parser.add_argument(
        "--samplers",
        type=str,
        nargs="+",
        default=["nsgaii", "tpe", "gp"],
        help="Sampler to use for the optimization.",
    )
    parser.add_argument(
        "--n_objectives",
        type=int,
        default=2,
        help="Number of objectives for the problem.",
    )
    parser.add_argument(
        "--dimension",
        type=int,
        default=3,
        help="Dimension of the problem (number of variables).",
    )
    parser.add_argument(
        "--constraint_type",
        type=int,
        default=1,
        help="Constraint type for the problem (1 or 2).",
    )
    parser.add_argument(
        "--function_id",
        type=int,
        default=2,
        help="Function ID for the DTLZ problem (1-7).",
    )
    parser.add_argument(
        "--n_trials",
        type=int,
        default=300,
        help="Number of trials for the optimization.",
    )
    parser.add_argument(
        "--n_seeds",
        type=int,
        default=5,
        help="Number of random seeds for the optimization.",
    )
    parser.add_argument(
        "--use_sf",
        type=bool,
        default=True,
        help="Use sans-serif font for the plot.",
    )
    args = parser.parse_args()

    main(args)

plot_hyprvolume_history.sh

#!/bin/bash

python plot_hypervolume_history.py --constraint_type 2 --function_id 2

Machine Specifications. The benchmarks are conducted on a computer running Arch Linux with Intel® Core™ i9-14900HX processor (24 cores, 32 threads, up to 5.8GHz) and Python 3.11.0.

Results

Figure 1 illustrates the obtained Pareto fronts, while Figure 2 shows the history of the best feasible hypervolume across trials. For the detailed discussions, see our blog post.

cdtlz_C2-DTLZ2_n_objectives2_dim3_trial300_gp_seed42_pareto_front_sf

(a) `GPSampler` w/ constraints.

cdtlz_C2-DTLZ2_n_objectives2_dim3_trial300_gp_wo_constraints_seed42_pareto_front_sf

(b) `GPSampler` w/o constraints.

cdtlz_C2-DTLZ2_n_objectives2_dim3_trial300_tpe_seed42_pareto_front_sf

(c) `TPESampler` w/ constraints.

cdtlz_C2-DTLZ2_n_objectives2_dim3_trial300_nsgaii_seed42_pareto_front_sf

(d) `NSGAIISampler` w/ constraints.

Figure 1. Obtained Pareto fronts for the C2-DTLZ2 problem after 300 trials. The results show that the constraint-aware GPSampler (a) effectively reduces wasted evaluations in infeasible regions compared to GPSampler without constraint handling (b) and TPESampler (c), while NSGAIISampler (d) remains far from convergence after 300 trials.


cdtlz_C2-DTLZ2_n_objectives2_dim3_trial300_hypervolume_history_sf

Figure2. History of the best feasible hypervolume on the C2-DTLZ2 problem 1. The solid lines denote the mean, and the shaded regions denote the standard error, both computed over five independent runs with different random seeds. GPSampler with constraint handling achieves faster convergence to higher hypervolume values compared to TPESampler and NSGAIISampler, demonstrating the effectiveness of the new feature in reducing evaluation cost.

Footnotes

  1. Jain, H., and Deb, K. An Evolutionary Many-Objective Optimization Algorithm Using Reference-Point Based Nondominated Sorting Approach, Part II: Handling Constraints and Extending to an Adaptive Approach. In IEEE Transactions on Evolutionary Computation, 18(4):602–622, 2014. 2 3

@kAIto47802 kAIto47802 marked this pull request as draft July 30, 2025 10:23
@nabenabe0928 nabenabe0928 self-assigned this Jul 31, 2025
@nabenabe0928 nabenabe0928 added the feature Change that does not break compatibility, but affects the public interfaces. label Jul 31, 2025
@nabenabe0928 nabenabe0928 added this to the v4.5.0 milestone Aug 1, 2025
@kAIto47802 kAIto47802 marked this pull request as ready for review August 1, 2025 07:39
Copy link
Copy Markdown
Contributor

@nabenabe0928 nabenabe0928 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Copy link
Copy Markdown
Member

@y0z y0z left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@nabenabe0928 nabenabe0928 merged commit 0a3dceb into optuna:master Aug 1, 2025
14 checks passed
@nabenabe0928 nabenabe0928 changed the title Add support for constrained multi-objective optimization in GPSmpler Add support for constrained multi-objective optimization in GPSampler Aug 1, 2025
@y0z y0z unassigned y0z and nabenabe0928 Aug 4, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Change that does not break compatibility, but affects the public interfaces.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants