qPOTS-Decoupled
The task-correlation gate and oracle-subset rule are described in the companion methods manuscript, qPOTS-Decoupled: Batch Pareto Optimal Thompson Sampling with Decoupled Oracle Evaluations for Constrained Multiobjective Bayesian Optimization (Renganathan, Carlson, and Bachman, manuscript under review). There is not yet a public preprint for that manuscript. The underlying qPOTS posterior-sampling method is published in the AISTATS 2025 proceedings and is also available as an arXiv preprint. This guide focuses on the implemented decoupled-evaluation interfaces.
What is an oracle?
An oracle is one measurable model output: an objective or a constraint. In a coupled evaluation, every oracle is evaluated whenever a design is selected. In a decoupled evaluation, individual oracles can be queried separately.
Examples include aerodynamic coefficients requiring different solver runs, material properties measured by different laboratory procedures, or constraints evaluated by analyses separate from the objective simulation. Decoupling can reduce total evaluation cost when not every output is needed at every selected design.
Mode |
Observation pattern |
qPOTS configuration |
|---|---|---|
Standard coupled qPOTS |
All outputs at every design, modeled independently |
|
Coupled multitask qPOTS |
All outputs at every design, modeled jointly |
|
qPOTS-Decoupled |
A selected subset of outputs at each design |
|
How task selection works
qPOTS-Decoupled adds an output-selection step after qPOTS proposes candidate locations:
A multitask Gaussian process is fit jointly across objective and constraint outputs. Missing observations are omitted from model fitting.
qPOTS draws a posterior sample path and selects a diverse candidate batch.
At each candidate, the posterior covariance between output tasks is converted to a total-correlation score.
If the absolute score is greater than
correlation_threshold, a Gaussian mutual-information criterion chooses an informative output subset.Otherwise, all outputs are selected at that candidate.
Selected values are appended to the training data; unselected entries are represented by
NaNand ignored by the next multitask fit.
The visualization shows task-specific observations for Branin and Currin, the posterior total-correlation field used for gating, and uncertainty reduction from information sharing between tasks.
Generate figure
The qPOTS-Decoupled script refits the final multitask model and saves both the visualization and the plotted arrays:
python -m examples.decoupled_branin_currin_figure \
--output assets/qpots_doe_total_correlation.png
Use the reduced CPU configuration to verify the workflow without replacing the documentation asset:
python -m examples.decoupled_branin_currin_figure --quick \
--output /tmp/qpots-decoupled-quick.png
These commands assume a source checkout. With a PyPI-only installation, save
the complete script below as main.py and replace
python -m examples.decoupled_branin_currin_figure with python main.py.
See Running the repository examples.
Note
qPOTS uses stochastic posterior sampling and evolutionary optimization. Consequently, results generated by this example may differ slightly from those displayed here, even when using the configured seed.
1"""Reproduce the qPOTS-Decoupled Branin--Currin documentation figure."""
2
3from __future__ import annotations
4
5import argparse
6from pathlib import Path
7
8import matplotlib.pyplot as plt
9import numpy as np
10import torch
11
12from qpots import Function, ModelObject, QPOTSConfig, QPOTSRunner, RuntimeConfig
13from qpots.utils.tc_utils import corr_and_total_correlation
14
15
16def fit_initial_model(result, config: QPOTSConfig, problem: Function) -> ModelObject:
17 """Fit the fully observed initial design for the uncertainty comparison."""
18 model = ModelObject(
19 train_x=result.train_x_normalized[: config.n_initial],
20 train_y=result.train_y[: config.n_initial],
21 bounds=problem.get_bounds(),
22 nobj=2,
23 ncons=0,
24 ntrain=config.n_initial,
25 runtime=problem.runtime,
26 )
27 model.fit_multitask_gp()
28 return model
29
30
31def total_correlation_grid(model, grid_size: int, runtime: RuntimeConfig):
32 """Evaluate posterior total correlation over a regular two-dimensional grid."""
33 axis = torch.linspace(0.0, 1.0, grid_size, device=runtime.device, dtype=runtime.dtype)
34 x1, x2 = torch.meshgrid(axis, axis, indexing="xy")
35 points = torch.column_stack((x1.reshape(-1), x2.reshape(-1)))
36 values = []
37 with torch.no_grad():
38 for point in points:
39 covariance = model.posterior(point.unsqueeze(0)).distribution.covariance_matrix
40 _, total_correlation = corr_and_total_correlation(covariance)
41 value = float("nan") if total_correlation is None else float(total_correlation)
42 values.append(max(value, 0.0))
43 return axis.cpu(), torch.tensor(values).reshape(grid_size, grid_size)
44
45
46def selected_task_points(result, task: int) -> torch.Tensor:
47 """Collect infill locations where a specific output task was queried."""
48 selected = []
49 for step in result.iterations:
50 if step.task_ids is None:
51 continue
52 mask = ~torch.isnan(step.task_ids[:, task])
53 if mask.any():
54 selected.append(step.candidate_x[mask].detach().cpu())
55 return torch.row_stack(selected) if selected else torch.empty((0, 2), dtype=torch.float64)
56
57
58def plot_figure(result, initial_model, grid_size: int, output: Path) -> None:
59 """Plot task observations, posterior total correlation, and uncertainty reduction."""
60 final_model = result.model.models[0]
61 initial_mtgp = initial_model.models[0]
62 axis, total_correlation = total_correlation_grid(final_model, grid_size, initial_model.runtime)
63 task_zero = selected_task_points(result, 0)
64 task_one = selected_task_points(result, 1)
65
66 diagonal = torch.linspace(
67 0.0,
68 1.0,
69 100,
70 device=initial_model.device,
71 dtype=initial_model.dtype,
72 )
73 probe = torch.column_stack((diagonal, diagonal))
74 with torch.no_grad():
75 initial_std = initial_mtgp.posterior(probe).variance.sqrt().cpu()
76 final_std = final_model.posterior(probe).variance.sqrt().cpu()
77
78 figure, axes = plt.subplots(1, 3, figsize=(15, 4.3), layout="constrained")
79 initial_x = result.train_x[: initial_model.ntrain].detach().cpu()
80 axes[0].scatter(*initial_x.T, color="#b8b8b8", s=22, label="initial: both tasks")
81 if task_zero.numel():
82 axes[0].scatter(*task_zero.T, marker="o", color="#2979b8", label="Branin queried")
83 if task_one.numel():
84 axes[0].scatter(*task_one.T, marker="^", color="#d35454", label="Currin queried")
85 axes[0].set(xlabel=r"$x_1$", ylabel=r"$x_2$", title="Task-specific observations")
86 axes[0].legend(fontsize=8)
87
88 contour = axes[1].contourf(axis, axis, total_correlation, levels=16, cmap="viridis")
89 figure.colorbar(contour, ax=axes[1], label="total correlation [nats]")
90 axes[1].set(xlabel=r"$x_1$", ylabel=r"$x_2$", title="Final posterior dependence")
91
92 colors = ("#2979b8", "#d35454")
93 labels = ("Branin", "Currin")
94 for task, (color, label) in enumerate(zip(colors, labels, strict=True)):
95 axes[2].plot(
96 diagonal.cpu(),
97 initial_std[:, task],
98 color=color,
99 linestyle="--",
100 label=f"{label}: initial",
101 )
102 axes[2].plot(diagonal.cpu(), final_std[:, task], color=color, label=f"{label}: final")
103 axes[2].set(
104 xlabel=r"diagonal location $x_1=x_2$",
105 ylabel="posterior standard deviation",
106 title="Uncertainty reduction",
107 )
108 axes[2].legend(fontsize=8)
109
110 output.parent.mkdir(parents=True, exist_ok=True)
111 figure.savefig(output, dpi=220, bbox_inches="tight")
112 data_output = output.with_suffix(".npz")
113 np.savez(
114 data_output,
115 train_x=result.train_x.detach().cpu().numpy(),
116 train_y=result.train_y.detach().cpu().numpy(),
117 task_zero=task_zero.numpy(),
118 task_one=task_one.numpy(),
119 grid_axis=axis.numpy(),
120 total_correlation=total_correlation.numpy(),
121 probe=probe.detach().cpu().numpy(),
122 initial_std=initial_std.numpy(),
123 final_std=final_std.numpy(),
124 )
125 print(f"Saved {output} and {data_output}")
126
127
128def parse_args() -> argparse.Namespace:
129 """Parse reproduction settings."""
130 parser = argparse.ArgumentParser()
131 parser.add_argument("--n-initial", type=int, default=20)
132 parser.add_argument("--iterations", type=int, default=10)
133 parser.add_argument("--batch-size", type=int, default=2)
134 parser.add_argument("--generations", type=int, default=8)
135 parser.add_argument("--threshold", type=float, default=1e-8)
136 parser.add_argument("--grid-size", type=int, default=20)
137 parser.add_argument("--seed", type=int, default=1023)
138 parser.add_argument(
139 "--output",
140 type=Path,
141 default=Path("assets/qpots_doe_total_correlation.png"),
142 )
143 parser.add_argument("--quick", action="store_true", help="Run a small CPU smoke test.")
144 return parser.parse_args()
145
146
147def main() -> None:
148 """Run qPOTS-Decoupled and generate its documentation figure."""
149 args = parse_args()
150 if args.quick:
151 args.n_initial = 8
152 args.iterations = 1
153 args.batch_size = 1
154 args.generations = 2
155 args.grid_size = 6
156
157 runtime = RuntimeConfig(device="cpu", dtype=torch.float64)
158 problem = Function("branincurrin", dim=2, nobj=2, runtime=runtime)
159 config = QPOTSConfig(
160 n_initial=args.n_initial,
161 iterations=args.iterations,
162 batch_size=args.batch_size,
163 generations=args.generations,
164 multitask=True,
165 partial_evaluations=True,
166 correlation_threshold=args.threshold,
167 seed=args.seed,
168 refit_final_model=True,
169 )
170 result = QPOTSRunner(problem, config, runtime=runtime).run()
171 initial_model = fit_initial_model(result, config, problem)
172 plot_figure(result, initial_model, args.grid_size, args.output)
173
174
175if __name__ == "__main__":
176 main()
Configure qPOTS-Decoupled
The constrained OSY benchmark has two objectives followed by six constraints:
from qpots import Function, QPOTSConfig, QPOTSRunner
problem = Function("osy", dim=6, nobj=2)
config = QPOTSConfig(
n_initial=60,
iterations=50,
batch_size=2,
n_constraints=6,
generations=20,
multitask=True,
partial_evaluations=True,
correlation_threshold=1e-4,
seed=1023,
)
result = QPOTSRunner(problem, config).run()
partial_evaluations=True requires multitask=True. The initial designs
are fully observed so the multitask model has data for every output before it
begins selecting subsets.
Output and task ordering
Output columns and task indices always use this order:
objective tasks
0throughnobj - 1;constraint tasks
nobjthroughnobj + n_constraints - 1.
Each qpots.runner.IterationResult contains:
candidate_xCandidate designs in physical coordinates.
task_idsA matrix with one row per candidate and one column per possible output. A numeric entry is the selected task index;
NaNmeans that output was not selected.observed_valuesObjective and constraint values retained for model training. Unqueried entries are
NaN.evaluationsThe complete
qpots.function.EvaluationResultreturned by the benchmark function.
Count scalar oracle evaluations with:
import torch
queried = sum(
int((~torch.isnan(step.observed_values)).sum())
for step in result.iterations
)
coupled = config.iterations * config.batch_size * (problem.nobj + config.n_constraints)
print(f"Selected {queried} of {coupled} possible infill oracle evaluations")
Choose a threshold
A numeric correlation_threshold enables the total-correlation gate. A
smaller positive threshold generally makes subset selection more frequent; a
larger threshold requires stronger posterior dependence before outputs are
decoupled. The useful scale is problem-dependent, so compare oracle counts and
optimization quality across several thresholds.
correlation_threshold=None uses random task subsets and is mainly useful as
a diagnostic or experimental baseline. It does not apply the
total-correlation gate.
Benchmarking versus real oracle calls
The bundled benchmark runner computes the complete output vector so that ground-truth performance can be assessed, then exposes only the selected entries to model training. This emulates partial observations but does not save computation for an in-process benchmark function.
For a real experiment or external simulator, obtain candidate locations and
task_ids from the lower-level qpots.acquisition.Acquisition.qpots()
interface before invoking the expensive oracles. Query only those task IDs,
store skipped outputs as NaN, and refit the multitask model. The
Decoupled OSY example demonstrates the data representation and task
accounting used by this workflow.
Assumptions and limitations
Outputs must share the same design-variable space.
Useful decoupling depends on learnable cross-task correlation.
Every output needs enough initial or accumulated data to fit the multitask model reliably.
Hypervolume is computed from objective columns only and must filter infeasible designs using the constraint columns.
qPOTS considers constraints feasible when their values are nonnegative.