Skip to content

Refactor acquisition function minimally#6166

Merged
nabenabe0928 merged 3 commits intooptuna:masterfrom
nabenabe0928:code-fix/refactor-gp3-0
Jun 27, 2025
Merged

Refactor acquisition function minimally#6166
nabenabe0928 merged 3 commits intooptuna:masterfrom
nabenabe0928:code-fix/refactor-gp3-0

Conversation

@nabenabe0928
Copy link
Copy Markdown
Contributor

Motivation

This PR introduces the abstraction of the acquisition function for GPSampler.

Description of the changes

  • Eliminate data class
  • Make the acquisition function abstract

@nabenabe0928 nabenabe0928 marked this pull request as ready for review June 18, 2025 05:28
@nabenabe0928 nabenabe0928 added the code-fix Change that does not change the behavior, such as code refactoring. label Jun 18, 2025
@nabenabe0928 nabenabe0928 added this to the v4.5.0 milestone Jun 18, 2025
@contramundum53 contramundum53 requested a review from Copilot June 18, 2025 05:30
@nabenabe0928
Copy link
Copy Markdown
Contributor Author

@sawa3030 @contramundum53
Could you review this PR?

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

A refactor to introduce a class-based abstraction for acquisition functions in GPSampler and remove the old dataclass/API.

  • Replace the AcquisitionFunctionParams and free-function API with BaseAcquisitionFunc subclasses (LogEI, LogPI, UCB, LCB, ConstrainedLogEI, LogEHVI).
  • Update all samplers, terminators, tests, and optimization helpers to use the new class-based API.
  • Extend GPRegressor with internal caching (_cache_matrix) and simplify its kernel/posterior interfaces.

Reviewed Changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated no comments.

Show a summary per file
File Description
tests/samplers_tests/test_gp.py Updated import and use of LogEI class.
tests/gp_tests/test_gp.py Adjusted GPRegressor instantiation to new signature.
tests/gp_tests/test_acqf.py Switched tests to the new BaseAcquisitionFunc API.
optuna/terminator/improvement/evaluator.py Replaced create_acqf_params with class-based calls.
optuna/terminator/improvement/emmr.py Removed old acqf_params code; use gpr.posterior.
optuna/samplers/_gp/sampler.py Refactored sampler methods to accept BaseAcquisitionFunc.
optuna/_gp/optim_sample.py Deleted obsolete optimize_acqf_sample.
optuna/_gp/optim_mixed.py Updated helpers to use BaseAcquisitionFunc.
optuna/_gp/gp.py Added caching, revised kernel & posterior API.
optuna/_gp/acqf.py Implemented BaseAcquisitionFunc and subclasses.
Comments suppressed due to low confidence (2)

optuna/samplers/_gp/sampler.py:232

  • Newly constructed GPRegressor instances in constraints_gprs lack a call to _cache_matrix(), so their posterior will assert on missing cached matrices. Call gpr._cache_matrix() right after instantiation before appending.
            )

optuna/_gp/acqf.py:231

  • The stabilizing_noise annotation appears in the function body instead of the __init__ signature, causing a syntax error. Move it into the parameter list and remove the misplaced closing parenthesis.
        stabilizing_noise: float = 1e-12,

Copy link
Copy Markdown
Member

@contramundum53 contramundum53 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
Copy link
Copy Markdown
Contributor Author

import optuna


def objective(trial: optuna.Trial) -> float:
    x = trial.suggest_float("x", -5, 5)
    y = trial.suggest_float("y", -5, 5)
    return x**2 + y**2


def multi_objective(trial: optuna.Trial) -> tuple[float, float]:
    x = trial.suggest_float("x", -5, 5)
    y = trial.suggest_float("y", -5, 5)
    return x**2 + y**2, (x - 2)**2 + (y - 2)**2


def constraints(trial: optuna.trial.FrozenTrial) -> tuple[float, float]:
    x = trial.params["x"]
    y = trial.params["y"]
    return (x - 2, y - 2)


mode = ["single", "multi", "constr"][2]
if mode == "single":
    study = optuna.create_study(sampler=optuna.samplers.GPSampler(seed=0))
    obj_func = objective
elif mode == "multi":
    study = optuna.create_study(sampler=optuna.samplers.GPSampler(seed=0), directions=["minimize"]*2)
    obj_func = multi_objective
elif mode == "constr":
    study = optuna.create_study(sampler=optuna.samplers.GPSampler(seed=0, constraints_func=constraints))
    obj_func = objective

study.optimize(obj_func, n_trials=20)
trials = study.trials
print((trials[-1].datetime_complete - trials[0].datetime_start).total_seconds())

The benchmarking code to check the reproducibility.

Comment on lines -92 to -105
def logpi(mean: torch.Tensor, var: torch.Tensor, f0: float) -> torch.Tensor:
# Return the integral of N(mean, var) from -inf to f0
# This is identical to the integral of N(0, 1) from -inf to (f0-mean)/sigma
# Return E_{y ~ N(mean, var)}[bool(y <= f0)]
sigma = torch.sqrt(var)
return torch.special.log_ndtr((f0 - mean) / sigma)


def ucb(mean: torch.Tensor, var: torch.Tensor, beta: float) -> torch.Tensor:
return mean + torch.sqrt(beta * var)


def lcb(mean: torch.Tensor, var: torch.Tensor, beta: float) -> torch.Tensor:
return mean - torch.sqrt(beta * var)
Copy link
Copy Markdown
Collaborator

@sawa3030 sawa3030 Jun 20, 2025

Choose a reason for hiding this comment

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

[note] Reviewed this part

Copy link
Copy Markdown
Collaborator

@sawa3030 sawa3030 left a comment

Choose a reason for hiding this comment

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

These are my notes to help with understanding and to keep track of what to review.

Comment on lines -108 to -115
# TODO(contramundum53): consider abstraction for acquisition functions.
# NOTE: Acquisition function is not class on purpose to integrate numba in the future.
class AcquisitionFunctionType(IntEnum):
LOG_EI = 0
UCB = 1
LCB = 2
LOG_PI = 3
LOG_EHVI = 4
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[note]: Reviewed this part

assert (
self._cov_Y_Y_inv is not None and self._cov_Y_Y_inv_Y is not None
), "Call cache_matrix before calling posterior."
cov_fx_fX = self.kernel(x[..., None, :], self._X_train)[..., 0, :]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[TODO]: Check if X equals to self._X_train

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Done

# We apply the cholesky decomposition to efficiently compute log(|C|) and C^-1.

cov_fX_fX = self.kernel(is_categorical, X, X)
cov_fX_fX = self.kernel(self._X_train, self._X_train)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[TODO]: Check if X equals to self._X_train

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Done

:, 0
]
cov_Y_Y_chol_inv_Y = torch.linalg.solve_triangular(
cov_Y_Y_chol, self._y_train[:, None], upper=False
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[TODO]: Check if Y equals to self._y_train

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Done

beta=acqf_params.beta,
acqf_stabilizing_noise=acqf_params.acqf_stabilizing_noise,
acqf_params_for_constraints=acqf_params_for_constraints,
class BaseAcquisitionFunc(ABC):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[TODO]: Verify that each member function in every AcquisitionFunction subclass corresponds to the appropriate function in the previous implementation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Checked LogEI, UCB, LCB, and LGEHVI

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Checked ConstrainedLogEI and LogPI

return f_val


def eval_acqf_no_grad(acqf_params: AcquisitionFunctionParams, x: np.ndarray) -> np.ndarray:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[TODO]: Review the implementation of eval_acqf_no_grad in this PR.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Done

return eval_acqf(acqf_params, torch.from_numpy(x)).detach().numpy()


def eval_acqf_with_grad(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[TODO]: Review the implementation of eval_acqf_with_grad in this PR.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Done

@sawa3030
Copy link
Copy Markdown
Collaborator

import optuna


def objective(trial: optuna.Trial) -> float:
    x = trial.suggest_float("x", -5, 5)
    y = trial.suggest_float("y", -5, 5)
    return x**2 + y**2


def multi_objective(trial: optuna.Trial) -> tuple[float, float]:
    x = trial.suggest_float("x", -5, 5)
    y = trial.suggest_float("y", -5, 5)
    return x**2 + y**2, (x - 2)**2 + (y - 2)**2


def constraints(trial: optuna.trial.FrozenTrial) -> tuple[float, float]:
    x = trial.params["x"]
    y = trial.params["y"]
    return (x - 2, y - 2)


mode = ["single", "multi", "constr"][2]
if mode == "single":
    study = optuna.create_study(sampler=optuna.samplers.GPSampler(seed=0))
    obj_func = objective
elif mode == "multi":
    study = optuna.create_study(sampler=optuna.samplers.GPSampler(seed=0), directions=["minimize"]*2)
    obj_func = multi_objective
elif mode == "constr":
    study = optuna.create_study(sampler=optuna.samplers.GPSampler(seed=0, constraints_func=constraints))
    obj_func = objective

study.optimize(obj_func, n_trials=20)
trials = study.trials
print((trials[-1].datetime_complete - trials[0].datetime_start).total_seconds())

The benchmarking code to check the reproducibility.

note: A slight difference in results between the master branch and this PR was observed during multi-objective optimization. This discrepancy stems from minor errors in numerical calculations between NumPy and PyTorch. This difference is negligible and can be safely ignored in this context.

Copy link
Copy Markdown
Collaborator

@sawa3030 sawa3030 left a comment

Choose a reason for hiding this comment

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

Thank you for your support in reviewing this PR. LGTM

@nabenabe0928
Copy link
Copy Markdown
Contributor Author

@sawa3030 @contramundum53
Thank you for your reviews, I will merge this PR:)

@nabenabe0928 nabenabe0928 merged commit 684f10a into optuna:master Jun 27, 2025
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

code-fix Change that does not change the behavior, such as code refactoring.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants