Constrained optimization
Constrained Bayesian Optimization with EI and UCB¶
In this tutorial we demonstrate the use of Xopt to perform Bayesian Optimization on a simple test problem subject to a single constraint. We will compare two acquisition functions:
- Expected Improvement (EI) - balances exploitation and exploration by targeting areas with high probability of improvement
- Upper Confidence Bound (UCB) - uses an explicit $\beta$ parameter to balance exploration and exploitation. However, it requires special consideration when using with constraints
Define the test problem¶
Here we define a simple optimization problem, where we attempt to minimize the sin function in the domian [0,2*pi], subject to a cos constraining function.
from xopt.evaluator import Evaluator
from xopt.generators.bayesian import (
ExpectedImprovementGenerator,
UpperConfidenceBoundGenerator,
)
from xopt import Xopt
from xopt.vocs import VOCS, select_best
import time
import math
import numpy as np
import matplotlib.pyplot as plt
# Ignore all warnings
import warnings
warnings.filterwarnings("ignore")
# define fixed seed
np.random.seed(42)
# define variables, function objective and constraining function
vocs = VOCS(
variables={"x": [0, 2 * math.pi]},
objectives={"f": "MINIMIZE"},
constraints={"c": ["LESS_THAN", 0]},
)
/home/runner/work/Xopt/Xopt/.venv/lib/python3.12/site-packages/pyro/ops/stats.py:527: SyntaxWarning: invalid escape sequence '\g'
we have :math:`ES^{*}(P,Q) \ge ES^{*}(Q,Q)` with equality holding if and only if :math:`P=Q`, i.e.
# define a test function to optimize
def test_function(input_dict):
return {"f": np.sin(input_dict["x"]), "c": np.cos(input_dict["x"] + 0.5)}
Constrained Bayesian Optimization with Expected Improvement (EI)¶
Create the evaluator to evaluate our test function and create a generator that uses
the Expected Improvement acquisition function to perform Bayesian Optimization. Note that because we are optimizing a problem with no noise we set use_low_noise_prior=True in the GP model constructor.
evaluator = Evaluator(function=test_function)
generator_ei = ExpectedImprovementGenerator(vocs=vocs)
generator_ei.gp_constructor.use_low_noise_prior = True
X_ei = Xopt(evaluator=evaluator, generator=generator_ei)
Generate and evaluate initial points for EI¶
To begin optimization, we must generate some random initial data points. The first call
to X.step() will generate and evaluate a number of randomly points specified by the
generator. Note that if we add data to xopt before calling X.step() by assigning
the data to X.data, calls to X.step() will ignore the random generation and
proceed to generating points via Bayesian optimization.
# call X.random_evaluate(n_samples) to generate + evaluate initial points
X_ei.random_evaluate(n_samples=3)
# inspect the gathered data
print("Initial data for EI optimization:")
print(X_ei.data)
Initial data for EI optimization:
x f c xopt_runtime xopt_error
0 3.929880 -0.709147 -0.278766 0.000015 False
1 0.309671 0.304745 0.689737 0.000003 False
2 1.683932 0.993607 -0.575435 0.000002 False
Do Bayesian optimization steps with EI¶
To perform optimization we simply call X.step() in a loop. This allows us to do
intermediate tasks in between optimization steps, such as examining the model and
acquisition function at each step (as we demonstrate here).
n_steps = 5
# test points for plotting
test_x = np.linspace(*X_ei.vocs.bounds[0], 50)
print("=== Expected Improvement Optimization ===")
for i in range(n_steps):
print(f"EI Step {i + 1}")
start = time.perf_counter()
# train model and visualize
model = X_ei.generator.train_model()
fig, ax = X_ei.generator.visualize_model(n_grid=100)
fig.suptitle(f"Expected Improvement - Step {i + 1}")
# add ground truth functions to plots
out = test_function({"x": test_x})
ax[0, 0].plot(test_x, out["f"], "C0-.", label="True objective", linewidth=2)
ax[1, 0].plot(test_x, out["c"], "C2-.", label="True constraint", linewidth=2)
ax[0, 0].legend()
ax[1, 0].legend()
plt.show()
print(f"Time: {time.perf_counter() - start:.3f}s")
# do the optimization step
X_ei.step()
print(f"Current best: f = {select_best(X_ei.vocs, X_ei.data)[1].item():.4f}")
print("-" * 40)
=== Expected Improvement Optimization === EI Step 1
Time: 10.289s
Current best: f = -0.7091 ---------------------------------------- EI Step 2
Time: 4.380s
Current best: f = -0.7091 ---------------------------------------- EI Step 3
Time: 4.140s
Current best: f = -0.8697 ---------------------------------------- EI Step 4
Time: 4.483s
Current best: f = -0.8737 ---------------------------------------- EI Step 5
Time: 4.223s
Current best: f = -0.8756 ----------------------------------------
# access the collected data from EI optimization
print("Final EI optimization results:")
print(X_ei.data)
print(f"EI Best valid solution: {select_best(X_ei.vocs, X_ei.data)[1].item()}")
Final EI optimization results:
x f c xopt_runtime xopt_error
0 3.929880 -0.709147 -0.278766 0.000015 False
1 0.309671 0.304745 0.689737 0.000003 False
2 1.683932 0.993607 -0.575435 0.000002 False
3 4.372015 -0.942630 0.158949 0.000008 False
4 5.464018 -0.730578 0.949497 0.000008 False
5 4.196277 -0.869744 -0.016112 0.000009 False
6 4.204418 -0.873733 -0.007971 0.000019 False
7 4.208209 -0.875571 -0.004180 0.000009 False
EI Best valid solution: -0.8755709213117987
Constrained Bayesian Optimization with Upper Confidence Bound (UCB)¶
The Upper Confidence Bound acquisition function allows a user to explicitly specify the balance between exploration and exploitation. However, for constrained optimization, there is an important technical requirement:
- Constraint handling requires positive acquisition values: When constraints are present, the acquisition function is weighted by the probability of feasibility, this will only work with strictly positive acquisition function values!.
- The
shiftparameter ensures positivity: Adding a positive shift ensures the UCB acquisition function is strictly positive, which is required for proper constraint weighting. Note that this addition will not change the location of the acquisition function maximum.
# Create UCB generator with shift parameter for constrained optimization
# The shift parameter ensures the acquisition function is strictly positive
# This is required because constrained optimization weights the acquisition
# function by the probability of feasibility: α_constrained = α_unconstrained × P(feasible)
generator_ucb = UpperConfidenceBoundGenerator(vocs=vocs, shift=2.0)
generator_ucb.gp_constructor.use_low_noise_prior = True
# Create new Xopt object for UCB
X_ucb = Xopt(evaluator=evaluator, generator=generator_ucb)
print("UCB Generator configuration:")
print(f"Shift parameter: {generator_ucb.shift}")
print("This shift ensures UCB values are strictly positive for constraint weighting")
UCB Generator configuration: Shift parameter: 2.0 This shift ensures UCB values are strictly positive for constraint weighting
# Generate initial points for UCB optimization
X_ucb.random_evaluate(n_samples=3)
print("Initial data for UCB optimization:")
print(X_ucb.data)
Initial data for UCB optimization:
x f c xopt_runtime xopt_error
0 2.521703 0.580945 -0.992822 0.000009 False
1 5.302891 -0.830661 0.886859 0.000002 False
2 5.303043 -0.830577 0.886929 0.000002 False
print("=== Upper Confidence Bound Optimization ===")
for i in range(n_steps):
print(f"UCB Step {i + 1}")
start = time.perf_counter()
# train model and visualize
model = X_ucb.generator.train_model()
fig, ax = X_ucb.generator.visualize_model(n_grid=100)
fig.suptitle(f"Upper Confidence Bound (shift={generator_ucb.shift}) - Step {i + 1}")
# add ground truth functions to plots
out = test_function({"x": test_x})
ax[0, 0].plot(test_x, out["f"], "C0-.", label="True objective", linewidth=2)
ax[1, 0].plot(test_x, out["c"], "C2-.", label="True constraint", linewidth=2)
ax[0, 0].legend()
ax[1, 0].legend()
plt.show()
print(f"Time: {time.perf_counter() - start:.3f}s")
# do the optimization step
X_ucb.step()
print(f"Current best: f = {select_best(X_ucb.vocs, X_ucb.data)[1].item():.4f}")
print("-" * 40)
=== Upper Confidence Bound Optimization === UCB Step 1
Time: 2.947s
Current best: f = 0.5809 ---------------------------------------- UCB Step 2
Time: 3.067s
Current best: f = -0.1785 ---------------------------------------- UCB Step 3
Time: 2.653s
Current best: f = -0.7402 ---------------------------------------- UCB Step 4
Time: 2.438s
Current best: f = -0.8634 ---------------------------------------- UCB Step 5
Time: 1.711s
Current best: f = -0.8634 ----------------------------------------
# access the collected data from UCB optimization
print("Final UCB optimization results:")
print(X_ucb.data)
print(f"UCB Best valid solution: {select_best(X_ucb.vocs, X_ucb.data)[1].item()}")
Final UCB optimization results:
x f c xopt_runtime xopt_error
0 2.521703 0.580945 -0.992822 0.000009 False
1 5.302891 -0.830661 0.886859 0.000002 False
2 5.303043 -0.830577 0.886929 0.000002 False
3 1.650403 0.996833 -0.547695 0.000007 False
4 3.321004 -0.178450 -0.777943 0.000010 False
5 3.974974 -0.740209 -0.235191 0.000009 False
6 4.183636 -0.863437 -0.028750 0.000008 False
7 4.105597 -0.821482 -0.106589 0.000008 False
UCB Best valid solution: -0.8634365655649928
Key Takeaways¶
Expected Improvement (EI):
- Natural balance between exploration and exploitation
- Works well out-of-the-box for both maximization and minimization
- Always positive, making it naturally compatible with constraints
- Tends to be more exploitative near the current best
Upper Confidence Bound (UCB) for Constrained Optimization:
- Requires
shiftparameter for constraints: UCB can be negative, but constrained optimization requires positive acquisition values - Technical requirement:
α_constrained(x) = α_unconstrained(x) × P(feasible|x)needsα_unconstrained(x) > 0 - Shift > 0: Ensures UCB acquisition function is strictly positive
With Constraints:
- Both acquisition functions handle constraints by incorporating constraint predictions
- The visualization shows both the objective model and constraint model
- Acquisition function values are weighted by probability of feasibility