Skip to content

Add BO termination conditions#724

Open
wgst wants to merge 25 commits into
experimental-design:mainfrom
wgst:termination-conditions
Open

Add BO termination conditions#724
wgst wants to merge 25 commits into
experimental-design:mainfrom
wgst:termination-conditions

Conversation

@wgst

@wgst wgst commented Feb 13, 2026

Copy link
Copy Markdown

Motivation

BO loops in BoFire currently run for a fixed number of iterations. In practice, especially for chemical experiments, where each iteration is very costly, we need a way to stop optimization early enough to reduce costs but still get a converged result.

This is an attempt to implement different termination conditions methods. The first method currently added in this PR is from Makarova, A. et al. (2022). The idea behind this method is to compute an upper bound on the simple regret of the current best recommendation using the GP posterior, and when this bound falls below a threshold ε_BO (derived from the observation noise variance), then we are confident that any further improvement will be smaller than the noise, and optimization can stop.

Two other termination methods - issues with implementing

  1. EMSR Gap: Ishibashi, H. et al. (2023). This method requires computing an Expected Minimum Simple Regret gap between consecutive iterations, which needs per-iteration model snapshots or knowledge of which experiments belong to which iteration. I have not found any tracking functionalities within BoFire, which makes it hard to implement this method at the moment.

  2. Probabilistic Regret Bound (PRB): Wilson, J. et al. (2024). This is a more probabilistic method, in which a statistically valid stopping rule is constructed using Matheron-rule pathwise trajectory samples (Thompson-style posterior samples via pathwise conditioning). Their implementation is based on TensorFlow and BO packages around it, and some crucial functionalities are not available in BoTorch.

Both methods could be good candidates for future work. Adding the method by Ishibashi et al should be fairly easy once the tracking architecture is settled. Method by Wilson could be a bit more problematic, but let's see.

Have you read the Contributing Guidelines on pull requests?

Yes

Test Plan

Added tests:

  • data model (test_termination.py)
  • evaluator (test_evaluator.py)
  • mapper (test_mapper.py)

Best,
Wojciech

Copilot AI review requested due to automatic review settings February 13, 2026 02:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds support for Bayesian Optimization termination conditions to allow BO runs to stop early based on convergence criteria (initially: the UCB–LCB simple-regret upper bound inspired by Makarova et al., 2022). This integrates termination evaluation into the existing run() benchmark loop and adds a small termination submodule with data models, evaluators, and mapping utilities.

Changes:

  • Introduces termination-condition data models (MaxIterationsTermination, AlwaysContinue, UCBLCBRegretTermination, CombiTerminationCondition).
  • Adds a termination evaluator (UCBLCBRegretEvaluator) and mapper utilities to determine which evaluators are needed.
  • Extends the benchmark runner to optionally stop early and to return a richer RunResult including termination metadata (plus tests).

Reviewed changes

Copilot reviewed 11 out of 13 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
bofire/data_models/termination/termination.py New termination-condition data models + should_terminate logic.
bofire/data_models/termination/api.py Exports termination-condition data models.
bofire/data_models/termination/init.py Package init (empty).
bofire/termination/evaluator.py Implements evaluator computing UCB–LCB regret bound + noise estimate.
bofire/termination/mapper.py Maps conditions to evaluators; recursively gathers needed evaluators.
bofire/termination/api.py Exports evaluator + mapping helpers.
bofire/termination/init.py Package init (empty).
bofire/runners/run.py Adds termination_condition support, early-stop logic, and new RunResult return type.
bofire/runners/api.py Re-exports RunResult from runners API.
tests/bofire/data_models/test_termination.py Unit tests for termination condition data models + serialization.
tests/bofire/termination/test_evaluator.py Unit + integration tests for evaluator and run() early termination behavior.
tests/bofire/termination/test_mapper.py Tests for termination mapper and evaluator discovery.
tests/bofire/termination/init.py Test package init.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread bofire/termination/evaluator.py Outdated
Comment thread bofire/termination/evaluator.py Outdated
Comment thread bofire/data_models/termination/termination.py Outdated
Comment thread bofire/runners/run.py Outdated
Comment thread bofire/data_models/termination/termination.py Outdated
Comment thread bofire/data_models/termination/termination.py Outdated
Comment thread bofire/termination/mapper.py Outdated
Comment thread bofire/termination/evaluator.py Outdated
Comment thread bofire/runners/api.py Outdated
Comment thread bofire/termination/evaluator.py Outdated
@jduerholt

Copy link
Copy Markdown
Contributor

Hi @wgst,

thanks for the PR and sorry for the late answer. @bertiqwerty will provide a thorough and more detailed review. One general comment from my side:

I think it would be the best to incorparate the termination criteria not into the runner but into the strategy itself, we have the StepwiseStrategy that can be used to compose different atomic strategies into one including conditions when to execute which one (https://github.com/experimental-design/bofire/blob/main/bofire/data_models/strategies/stepwise/stepwise.py https://github.com/experimental-design/bofire/blob/main/bofire/strategies/stepwise/stepwise.py) I think it would be nice, to implement the stopping criteria as a condition for a certain stop. What do you think?

Best,

JOhannes

return iteration >= self.max_iterations


class AlwaysContinue(TerminationCondition, EvaluatableTermination):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This one also already exisits in the StepwiseStrategy as a condition.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good idea! I have now moved everything to StepwiseStrategy, so I hope it's in the right place now.

@bertiqwerty

bertiqwerty commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

Thanks @wgst and @jduerholt. I agree with Johannes. Please use the StepwiseStrategy. If you need more guidance on how to do that, @wgst, let us have a call. I will have a closer look as soon as this refactored.

@bertiqwerty bertiqwerty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please refactor into the StepwiseStrategy.

wgst added 6 commits March 18, 2026 16:36
…LCB regret bound

- Move termination logic from runner into StepwiseStrategy conditions
- Add UCBLCBRegretBoundCondition with 4 threshold modes (gp_noise, range, cv, manual)
- Add UCBLCBRegretEvaluator with configurable beta (GP-UCB, Srinivas et al. 2010)
- Add top-q filtering support for regret bound computation
- Extract _min_ucb_evaluated() method for clarity
- Add OptimizationComplete exception for clean termination signaling
- Remove old termination data models, mapper, and tests (replaced by conditions)
- Add comprehensive test suite (test_condition.py, test_evaluator.py)
- All 49 tests passing
…evaluators

Option A refactoring: both UCBLCBRegretEvaluator and ExpMinRegretGapEvaluator
now support lcb_method parameter to choose between "sample" (default) and
"optimize" LCB computation over the domain.

Changes:
- Convert _ucb_lcb_regret_bound from static to instance method
- Add lcb_method parameter to ExpMinRegretGapEvaluator (was Makarova-only)
- Move _min_lcb_optimize to base TerminationEvaluator class for code reuse
- Unify LCB computation logic in both evaluators via _ucb_lcb_regret_bound
- Add threshold computation helpers module (thresholds.py)
- Update tests to verify both methods work correctly

Both evaluators maintain backward compatibility (default "sample" method).
Optimize method uses strategy's acqf_optimizer for more efficient LCB search.
…_min_lcb_optimize to base class

Convert _ucb_lcb_regret_bound from static to instance method to access self.lcb_method.
Move _min_lcb_optimize to TerminationEvaluator base class for code reuse by both
Makarova and Ishibashi evaluators. Both evaluators now flexibly support "sample"
(default) and "optimize" methods for LCB computation.
Refactor UCBLCBRegretBoundCondition and ExpMinRegretGapCondition to use new
threshold computation helpers from thresholds module. Update data model exports.
Update benchmark functions and exports to support termination condition testing.
Add tests for new lcb_method parameter in both Makarova and Ishibashi evaluators.
Verify both "sample" and "optimize" methods work correctly. Update condition specs
and evaluator tests to reflect refactored code structure.
wgst added 13 commits May 12, 2026 13:16
Moves each evaluator out of the monolithic evaluator.py into its own file
(ucb_lcb.py, exp_min_regret_gap.py, log_eipc.py), leaving evaluator.py with
just the TerminationEvaluator base class, and renames thresholds.py ->
utils.py.

Updates the api exports and the termination tests for the new layout. Pure
reorganisation; no behaviour change.
Implements the PRB stopping criterion from Wilson (2024), "Stopping
Bayesian Optimization with Probabilistic Regret Bounds":

- ProbabilisticRegretBoundEvaluator: draws GP posterior paths via
  Matheron's rule, minimises each path with multistart L-BFGS-B, and runs
  a sequential Clopper-Pearson level test (cumulative geometric sample
  schedule + power-law risk budget) matching the reference implementation
  (j-wilson/trieste_stopping). clopper_pearson_ci is added to utils.py.
- ProbabilisticRegretBoundCondition data model, wired into AnyCondition and
  CombiCondition and usable from StepwiseStrategy, plus a serialization spec.

Adds unit, integration and conformance tests: known-answer Psi on a cosine
GP, Clopper-Pearson formula identity, and the Algorithm 2 schedule /
risk-budget / type-I error behaviour.
- Register LogEIPCCondition in the condition specs so it gets serialization
  and deserialization test coverage, matching the other termination
  conditions (UCBLCB, ExpMinRegretGap, PRB).
- Seed the RandomStrategy initial design and the SoboStrategy acquisition
  optimizer in TestRegretBoundConvergence so the run is deterministic; the
  "generally decreases" assertion was previously flaky because the initial
  design used an unseeded RNG.
# Conflicts:
#	bofire/data_models/strategies/stepwise/conditions.py
Relocates bofire/termination/ -> bofire/strategies/stepwise/termination/ so
the termination feature lives entirely within the StepwiseStrategy paradigm,
per maintainer feedback ("use the StepwiseStrategy"). The condition data
models already live under data_models/strategies/stepwise/conditions.py and
run only via StepwiseStrategy; this moves the functional evaluators to mirror
that, removing the odd top-level bofire/termination/ package (which had no
matching data_models/termination/ counterpart).

Pure relocation: updates the import paths in the condition data models, the
package-internal imports, and the tests (also moved to
tests/bofire/strategies/stepwise/termination/). No behaviour change.
UCBLCBRegretBoundCondition built its evaluator with no arguments, so the
GP-UCB beta and LCB-sampling knobs were stuck at defaults.  Surface them as
condition fields: delta, beta_scale, n_samples_lcb, batch_size, lcb_method,
fallback_noise_variance (matching the subset ExpMinRegretGapCondition
already exposes; the deeper beta-formula internals stay internal).

ProbabilisticRegretBoundCondition now also exposes the Clopper-Pearson
sample-schedule knobs initial_batch and batch_growth.

Updates the serialization specs accordingly so the model_dump roundtrip
still matches exactly.
_objective_sign now returns +1 for MaximizeObjective and -1 for
MinimizeObjective, matching bofire.utils.multiobjective.get_ref_point_mask
and BoTorch's maximize convention (it previously used the inverse). The
regret evaluators work in a "lower is better" frame, so each derives its
minimisation-frame sign with a single negation (sign = -direction; LogEIPC
uses maximize = direction > 0). Behaviour is unchanged — confirmed by the
negation-invariance tests.
…aluators

- Un-standardize the learned likelihood noise exactly once in both
  automatic threshold paths: the Ishibashi adaptive threshold previously
  scaled it twice (noise * y_std**4) and the Makarova threshold not at
  all, making the default noise_variance=None modes mis-scaled whenever
  a Standardize outcome transform is active.  _get_noise_variance now
  returns standardized-space noise (the noise_var_override convention)
  and evaluate() applies the single * y_std**2 conversion.
- Compute the Ishibashi beta from the total observation count including
  the newly evaluated point, matching the reference (sqrt_beta from
  X.shape[0]) and the GP-UCB pairing of beta_t with the (t-1)-point
  posterior.
- Make the CV threshold incumbent objective-direction aware
  (compute_threshold_cv gains a sign argument; previously idxmin even
  for maximization).
- Move lcb_method ownership to the RegretBoundEvaluator base and cite
  Srinivas et al. (2010) in _compute_beta.
- Add tests pinning both noise scale conventions; pin the GP-noise
  condition test to deterministic threshold extremes.
wgst added 4 commits June 12, 2026 17:13
The best-fraction refit (topq/min_topq) lived in the data-model
condition; the evaluator now owns it via _apply_topq, keeping the
condition a thin delegate like the other termination conditions and
making the evaluator self-contained for direct use (default topq=1.0
preserves standalone behaviour; the condition passes its 0.5 default).

Also in the conditions module:
- pass the objective direction to the CV threshold,
- pass threshold_mode explicitly to the Ishibashi evaluator (it was
  implicitly relying on the adaptive_median default computing both
  thresholds),
- batch_size is now Optional[PositiveInt] = None (single posterior
  call by default) with specs updated, and the non-serialized state of
  ExpMinRegretGapCondition is documented.

Adds a test asserting top-q actually engages (beta computed from the
filtered count).
- Restore the temporarily overridden cost_callable in a try/finally so
  a failure during evaluation cannot leave the GP-fitted cost callable
  permanently installed on the evaluator.
- Suppress ty unresolved-attribute false positives on botorch Posterior
  attribute access (mean/variance/distribution exist on the GPyTorch
  posteriors used at runtime but not on the abstract base), matching
  the suppression idiom used elsewhere in the codebase.
- Move the OptimizationComplete import in test_stepwise.py to the top
  of the file (E402).
Cover Booth, SixHumpCamel, HolderTable, CrossInTray, Easom, and
Rosenbrock (2D default and dim=4) in the parametrized benchmark test,
verifying output shapes and that f evaluated at get_optima() matches
the claimed optimum.
run.py only differed from the PR base by an accidentally dropped
ty-ignore comment (the branch predates the pyright-to-ty migration);
restoring the base version removes the file from the PR diff.
@bertiqwerty

Copy link
Copy Markdown
Contributor

Sorry, for the delay. I will try to look into this next week.

@wgst

wgst commented Jun 12, 2026

Copy link
Copy Markdown
Author

Hi @jduerholt, and @bertiqwerty,

I implemented all four termination conditions that I intended to.

This includes:

  • Makarova et al. (2022) method (UCBLCBRegretBoundCondition), which tracks an upper bound on the simple regret (the gap min UCB(evaluated) - min LCB(domain) from GP-UCB confidence bounds), and stops the BO loop whenever it falls below a threshold. The threshold can be a chosen fixed value (e.g., experimental error), the GP's estimated noise, or a cross-validation-based estimate.
  • Ishibashi et al. (2023) method (ExpMinRegretGapCondition) that builds on Makarova et al. method by also evaluating the UCB-LCB regret bound, but it additionally introduces new terms, including the KL divergence between the predictive distribution of GP at the newest point before vs after conditioning on that new observation. The threshold can be adaptive (self-calibrating from the GP posterior variances and noise) or a median-based heuristic.
  • Xie. et al. (2023) method (LogEIPCCondition), a cost-aware stopping for when the cost of experiments plays a crucial role. It uses a cost-adjusted acquisition function, LogEIC(x) = LogEI(x) − α·log c(x), and stops when no candidate's expected improvement is worth its evaluation cost. Currently, the LogEIC acquisition function is implemented within the BoFire (temporarily). However, I already submitted a BoTorch PR, so that the acquisition functions stay in BoTorch, not BoFire: Add LogExpectedImprovementPerCost acquisition function meta-pytorch/botorch#3304.
  • Wilson, J. et al. (2024), the Probabilistic Regret Bound (PRB) method (ProbabilisticRegretBoundCondition), which utilizes Matheron-rule pathwise trajectory samples (posterior samples via pathwise conditioning), minimises each sampled path, and stops once a sequential Clopper–Pearson test certifies that P(regret ≤ ε) ≥ 1 − δ (where ε is the simple-regret tolerance and δ is the total risk budget, split between model risk and Monte-Carlo estimation risk, and both ε and δ are set by the user).

As suggested by you previously, I've also moved all the conditions to StepwiseStrategy.

Best,
Wojciech

@wgst wgst marked this pull request as ready for review June 12, 2026 17:18
@wgst wgst requested a review from bertiqwerty June 18, 2026 12:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants