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

multitask=False, partial_evaluations=False

Coupled multitask qPOTS

All outputs at every design, modeled jointly

multitask=True, partial_evaluations=False

qPOTS-Decoupled

A selected subset of outputs at each design

multitask=True, partial_evaluations=True

How task selection works

qPOTS-Decoupled adds an output-selection step after qPOTS proposes candidate locations:

  1. A multitask Gaussian process is fit jointly across objective and constraint outputs. Missing observations are omitted from model fitting.

  2. qPOTS draws a posterior sample path and selects a diverse candidate batch.

  3. At each candidate, the posterior covariance between output tasks is converted to a total-correlation score.

  4. If the absolute score is greater than correlation_threshold, a Gaussian mutual-information criterion chooses an informative output subset.

  5. Otherwise, all outputs are selected at that candidate.

  6. Selected values are appended to the training data; unselected entries are represented by NaN and ignored by the next multitask fit.

Decoupled Branin and Currin observations, total correlation, and uncertainty reduction from multitask modeling

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.

decoupled_branin_currin_figure.py
  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:

  1. objective tasks 0 through nobj - 1;

  2. constraint tasks nobj through nobj + n_constraints - 1.

Each qpots.runner.IterationResult contains:

candidate_x

Candidate designs in physical coordinates.

task_ids

A matrix with one row per candidate and one column per possible output. A numeric entry is the selected task index; NaN means that output was not selected.

observed_values

Objective and constraint values retained for model training. Unqueried entries are NaN.

evaluations

The complete qpots.function.EvaluationResult returned 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.