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 NaN and 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

decoupled_osy_example.py
  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.