Skip to content

Conversation

@sawa3030
Copy link
Collaborator

@sawa3030 sawa3030 commented Oct 21, 2025

Contributor Agreements

Please read the contributor agreements and if you agree, please click the checkbox below.

  • I agree to the contributor agreements.

Tip

Please follow the Quick TODO list to smoothly merge your PR.

Motivation

Add a trust-region Bayesian optimizer (TuRBO) to OptunaHub.

Description of the changes

Implements TuRBOSampler on based on GPSampler. Note that categorical parameters are currently unsupported, and multi-objective optimization is not available.

Please refer to the paper, Scalable Global Optimization via Local Bayesian Optimization for more information.

TODO List towards PR Merge

Please remove this section if this PR is not an addition of a new package.
Otherwise, please check the following TODO list:

  • Copy ./template/ to create your package
  • Replace <COPYRIGHT HOLDER> in LICENSE of your package with your name
  • Fill out README.md in your package
  • Add import statements of your function or class names to be used in __init__.py
  • (Optional) Add from __future__ import annotations at the head of any Python files that include typing to support older Python versions
  • Apply the formatter based on the tips in README.md
  • Check whether your module works as intended based on the tips in README.md

@sawa3030
Copy link
Collaborator Author

Performance

The performace was measured by comparing it with GPSampler using the Black-Box Optimization Benchmarking (BBOB) test suite on OptunaHub. Each sampler was run for 200 iterations and the experiment was repeated 10 times.

function_id = 1:

Sampler Best value (mean ± std) RunTime (mean ± std, s)
TuRBO 80.2633 ± 0.2363 8.62 ± 0.49
GP 79.4833 ± 0.0018 85.25 ± 10.12

function_id = 22:

Sampler Best value (mean ± std) RunTime (mean ± std, s)
TuRBO −987.1081 ± 5.4540 9.38 ± 0.58
GP −982.0005 ± 23.2204 51.39 ± 2.15

Although the best values are comparable, runtime is substantially reduced, especially at larger iteration counts —as expected for this approach.

@sawa3030
Copy link
Collaborator Author

For ease of review: Given that this sampler mainly extends GPSampler, focusing on the diff starting from c574e68 should be sufficient to verify the logic. Thank you in advance!

@sawa3030 sawa3030 marked this pull request as ready for review October 24, 2025 09:40
@c-bata c-bata added the new-package New packages label Oct 31, 2025
Copy link
Member

@gen740 gen740 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 the PR! I've left few comment.

Comment on lines 123 to 125
self._init_length = 0.8
self._max_length = 1.6
self._min_length = 0.5**7
Copy link
Member

Choose a reason for hiding this comment

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

How abount setting these values via constructor arguments?

Comment on lines 146 to 151
def reset_trust_region(self, delete_trust_region_id: int) -> None:
self._trial_ids_for_trust_region[delete_trust_region_id] = []
self._length[delete_trust_region_id] = self._init_length
self._n_consecutive_success[delete_trust_region_id] = 0
self._n_consecutive_failure[delete_trust_region_id] = 0
self._best_value_in_current_trust_region[delete_trust_region_id] = None
Copy link
Member

Choose a reason for hiding this comment

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

I think this function is internal, so it should start with an underscore.
Alternatively, it can be removed because it is used only once.

Comment on lines 195 to 207
def _get_best_params_for_multi_objective(
self,
normalized_params: np.ndarray,
standardized_score_vals: np.ndarray,
) -> np.ndarray:
pareto_params = normalized_params[
_is_pareto_front(-standardized_score_vals, assume_unique_lexsorted=False)
]
n_pareto_sols = len(pareto_params)
# TODO(nabenabe): Verify the validity of this choice.
size = min(self._n_local_search // 2, n_pareto_sols)
chosen_indices = self._rng.rng.choice(n_pareto_sols, size=size, replace=False)
return pareto_params[chosen_indices]
Copy link
Member

Choose a reason for hiding this comment

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

This function is not used, how about removing it.

def sample_relative(
self, study: Study, trial: FrozenTrial, search_space: dict[str, BaseDistribution]
) -> dict[str, Any]:
if search_space == {}:
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if search_space == {}:
if study._is_multi_objective():
raise ValueError("TurboSampler does not support multi-objective optimization.")
if search_space == {}:

How about checking whether this is multi-objective or not, as in the following code?

if study._is_multi_objective():
raise ValueError("CARBOSampler does not support multi-objective optimization.")
.

Comment on lines 215 to 218
for id in range(self._n_trust_region):
if len(self._trial_ids_for_trust_region[id]) < self._n_startup_trials:
self._trial_ids_for_trust_region[id].append(trial._trial_id)
return {}
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
for id in range(self._n_trust_region):
if len(self._trial_ids_for_trust_region[id]) < self._n_startup_trials:
self._trial_ids_for_trust_region[id].append(trial._trial_id)
return {}
if any(len(ids) < self._n_startup_trials for ids in self._trial_ids_for_trust_region):
return {}

These lines can be simplified.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thank you for the suggestion. While your approach simplifies the code significantly, in this case I need to append the trial_id to self._trial_ids_for_trust_region[id]. As you pointed out, the original code was somewhat hard to follow, so I have refactored it to make it clearer. I would appreciate any further suggestions or feedback you may have.

Comment on lines 232 to 234
if len(trials) < self._n_startup_trials:
self._trial_ids_for_trust_region[id].append(trial._trial_id)
return {}
Copy link
Member

Choose a reason for hiding this comment

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

These lines maybe redundant.

Comment on lines 239 to 244
_sign = np.array(
[-1.0 if d == StudyDirection.MINIMIZE else 1.0 for d in study.directions]
)
standardized_score_vals, _, _ = _standardize_values(
_sign * np.array([trial.values for trial in trials])
)
Copy link
Member

Choose a reason for hiding this comment

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

Turbo is single-objective only, so we can simplify this part.

sign = -1.0 if study.direction == StudyDirection.MINIMIZE else 1.0

Comment on lines 348 to 363
if direction == StudyDirection.MINIMIZE:
if values[0] < best_value:
self._n_consecutive_success[trust_region_id] += 1
self._n_consecutive_failure[trust_region_id] = 0
self._best_value_in_current_trust_region[trust_region_id] = values[0]
else:
self._n_consecutive_success[trust_region_id] = 0
self._n_consecutive_failure[trust_region_id] += 1
else:
if values[0] > best_value:
self._n_consecutive_success[trust_region_id] += 1
self._n_consecutive_failure[trust_region_id] = 0
self._best_value_in_current_trust_region[trust_region_id] = values[0]
else:
self._n_consecutive_success[trust_region_id] = 0
self._n_consecutive_failure[trust_region_id] += 1
Copy link
Member

Choose a reason for hiding this comment

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

This branch can be simplified by checking whether direction == StudyDirection.MINIMIZE != values[0] < best_value

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Although this is not a major issue, using (direction == StudyDirection.MINIMIZE) != (values[0] < best_value) allows the case where direction == StudyDirection.MAXIMIZE && values[0] == best_value, which is not ideal.
To make the logic clearer, I instead compute whether the value has improved first:

is_better = (
    values[0] < best_value
    if direction == StudyDirection.MINIMIZE
    else values[0] > best_value
)

Comment on lines 378 to 379
if self._length[trust_region_id] < self._min_length:
self.reset_trust_region(trust_region_id)
Copy link
Member

Choose a reason for hiding this comment

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

How about check this condition first?

@sawa3030
Copy link
Collaborator Author

Thank you for the suggestions on this large PR. I’ve incorporated some changes based on your feedback. PTAL!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new-package New packages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants