Decoupled OSY Example
This example demonstrates how to run qPOTS with decoupled evaluations on the constrained OSY benchmark.
OSY has two objectives and six inequality constraints. In a coupled workflow, every candidate is evaluated against all eight outputs. In a decoupled workflow, qPOTS can select which objectives or constraints to query at each candidate, reducing unnecessary oracle calls when outputs come from separate simulators, experiments, or analyses.
Overview
Optimizes the 6-dimensional OSY problem with 2 objectives and 6 constraints.
Uses a MultiTaskGP so objectives and constraints are modeled jointly.
Enables decoupled selection with
partial_info=1.Stores missing task evaluations as
NaNand refits the multitask model with partially observed data.Tracks true hypervolume from fully observed bookkeeping data.
Fills missing values with multitask posterior means for downstream analysis.
Key Settings
The important decoupled-evaluation settings are:
"mt": 1,
"partial_info": 1,
"threshold": 1e-4,
mt=1 enables the joint multitask model. partial_info=1 tells qPOTS to
return both candidate points and the selected task indices. threshold
controls the total-correlation gate used to decide when to query only a subset
of outputs; use None to decouple unconditionally.
Script Details
1"""
2Decoupled qPOTS-DOE example on the OSY benchmark.
3
4OSY is a 6-dimensional constrained problem with 2 objectives and 6 inequality
5constraints. In many real-world settings the objectives and constraints are
6measured by separate simulators or laboratory analyses, so evaluating all of
7them together at every candidate is wasteful. This example uses qPOTS-DOE to
8decide -- at each candidate point -- which oracle subset to query, based on
9the total posterior correlation among the multitask GP's tasks.
10
11Key settings
12------------
13mt=1 -- use a joint MultiTaskGP over all objectives + constraints
14partial_info=1 -- enable decoupled oracle selection
15threshold=1e-4 -- only decouple when total correlation exceeds this value;
16 set threshold=None for random (unconditional) decoupling
17"""
18
19import time
20import warnings
21
22import torch
23from botorch.utils.transforms import unnormalize
24
25from qpots.acquisition import Acquisition
26from qpots.config import DEFAULT_DEVICE, DEFAULT_DTYPE
27from qpots.function import Function
28from qpots.model_object import ModelObject
29from qpots.utils.utils import compute_true_hypervolume, posterior_mean_fill
30
31warnings.filterwarnings("ignore")
32
33# ---------------------------------------------------------------------------
34# Problem: OSY -- 6-D input, 2 objectives, 6 constraints
35# ---------------------------------------------------------------------------
36DIM = 6
37NOBJ = 2
38NCONS = 6
39
40# Reference point for hypervolume (negated objectives, so values are negative)
41REF_POINT = torch.tensor([-300.0, -15.0], device=DEFAULT_DEVICE, dtype=DEFAULT_DTYPE)
42
43settings = {
44 "ntrain": 10 * DIM, # 60 initial training points
45 "iters": 50,
46 "q": 2, # candidates per iteration
47 "wd": ".",
48 "ref_point": REF_POINT,
49 "dim": DIM,
50 "nobj": NOBJ,
51 "ncons": NCONS,
52 "nystrom": 0,
53 "nychoice": "pareto",
54 "ngen": 20,
55 # --- qPOTS-DOE settings ---
56 "mt": 1, # use MultiTaskGP
57 "partial_info": 1, # enable decoupled oracle selection
58 "threshold": 1e-4, # total-correlation gate (None = always decouple)
59}
60
61# ---------------------------------------------------------------------------
62# Setup
63# ---------------------------------------------------------------------------
64test_function = Function("osy", dim=settings["dim"], nobj=settings["nobj"])
65evaluate = test_function.evaluate
66get_cons = test_function.get_cons
67bounds = test_function.get_bounds()
68cons = get_cons()
69
70torch.manual_seed(1023)
71
72train_x = torch.rand(
73 settings["ntrain"], settings["dim"],
74 device=DEFAULT_DEVICE, dtype=DEFAULT_DTYPE,
75)
76train_y_obj = evaluate(unnormalize(train_x, bounds))
77train_y_cons = cons(unnormalize(train_x, bounds))
78train_y = torch.column_stack([train_y_obj, train_y_cons])
79
80# Keep a fully-observed copy for hypervolume tracking
81full_train_y = train_y.clone()
82
83# ---------------------------------------------------------------------------
84# Fit the initial MultiTaskGP (one joint GP over all NOBJ + NCONS tasks)
85# ---------------------------------------------------------------------------
86gps = ModelObject(
87 train_x=train_x,
88 train_y=train_y,
89 bounds=bounds,
90 nobj=settings["nobj"],
91 ncons=settings["ncons"],
92 ntrain=settings["ntrain"],
93 device=DEFAULT_DEVICE,
94)
95gps.fit_multitask_gp()
96
97acq = Acquisition(test_function, gps, cons=cons, device=DEFAULT_DEVICE, q=settings["q"])
98
99# Initial hypervolume (feasible points only)
100hv = compute_true_hypervolume(
101 full_train_y,
102 ref_point=REF_POINT,
103 nobj=NOBJ,
104 ncons=NCONS,
105 maximize=True,
106)
107print(f"Initial hypervolume: {hv:.4f}")
108
109# ---------------------------------------------------------------------------
110# Optimization loop
111# ---------------------------------------------------------------------------
112for iteration in range(settings["iters"]):
113 t0 = time.time()
114
115 # qPOTS-DOE returns (candidates, task_ids) when partial_info=1.
116 # task_ids[i] is a 1-D tensor of oracle indices actually queried for
117 # candidate i; un-queried tasks remain NaN in train_y.
118 new_x, new_task_ids = acq.qpots(bounds=bounds, iteration=iteration, **settings)
119
120 elapsed = time.time() - t0
121
122 # Evaluate the full oracle (all objectives + constraints) for bookkeeping,
123 # then mask out the tasks that were NOT selected by the MI subset rule.
124 full_new_y_obj = evaluate(unnormalize(new_x.reshape(-1, DIM), bounds))
125 full_new_y_cons = cons(unnormalize(new_x.reshape(-1, DIM), bounds))
126 full_new_y = torch.column_stack([
127 full_new_y_obj.reshape(new_x.shape[0], NOBJ),
128 full_new_y_cons.reshape(new_x.shape[0], NCONS),
129 ])
130
131 # Build the partially-observed new_y: NaN where the task was not queried.
132 new_y = torch.full_like(full_new_y, float("nan"))
133 for j in range(new_x.shape[0]):
134 cols = new_task_ids[j]
135 valid = ~torch.isnan(cols)
136 selected = cols[valid].long()
137 new_y[j, selected] = full_new_y[j, selected]
138
139 n_queried = (~torch.isnan(new_y)).sum().item()
140 n_total = new_y.numel()
141 print(
142 f"Iteration {iteration:3d} | "
143 f"oracles queried: {n_queried}/{n_total} | "
144 f"time: {elapsed:.2f}s | "
145 f"HV: {hv:.4f}"
146 )
147
148 # Update training data (partially observed rows use NaN for missing tasks)
149 train_x = torch.row_stack([train_x, new_x.view(-1, DIM)])
150 train_y = torch.row_stack([train_y, new_y])
151 full_train_y = torch.row_stack([full_train_y, full_new_y])
152
153 # Hypervolume on the fully-observed data (ground truth for comparison)
154 hv = compute_true_hypervolume(
155 full_train_y,
156 ref_point=REF_POINT,
157 nobj=NOBJ,
158 ncons=NCONS,
159 maximize=True,
160 )
161
162 # Refit the MultiTaskGP; NaN entries are handled internally by the model
163 gps = ModelObject(
164 train_x=train_x,
165 train_y=train_y,
166 bounds=bounds,
167 nobj=settings["nobj"],
168 ncons=settings["ncons"],
169 ntrain=settings["ntrain"],
170 device=DEFAULT_DEVICE,
171 )
172 gps.fit_multitask_gp()
173 acq = Acquisition(test_function, gps, cons=cons, device=DEFAULT_DEVICE, q=settings["q"])
174
175# ---------------------------------------------------------------------------
176# Fill NaN entries with MTGP posterior means for downstream analysis
177# ---------------------------------------------------------------------------
178train_y_filled = posterior_mean_fill(gps)
179print("\nOptimization complete.")
180print(f"Final hypervolume: {hv:.4f}")
181print(f"train_y shape (with NaNs filled): {train_y_filled.shape}")
Example Output
Initial hypervolume: 0.0000
Iteration 0 | oracles queried: 8/16 | time: 1.43s | HV: 0.0000
Iteration 1 | oracles queried: 10/16 | time: 1.37s | HV: 12.4815
...
Optimization complete.
Final hypervolume: 52.7342
train_y shape (with NaNs filled): torch.Size([160, 8])
Usage
Run the script locally with:
python examples/decoupled_osy_example.py
This example is more computationally expensive than the basic examples because it refits a multitask Gaussian process after each partially observed batch.