Skip to main content

quantecarlo

Batch Bayesian optimization for Optuna using q-Expected Improvement (q-EI). Two drop-in suggest_fn implementations for the optunahub BatchSampler:

Function Description
fantasize_suggest Self-contained in-process GP (numpy/scipy). No server required.
modal_suggest Delegates to a hosted GPU GP endpoint (Modal). Higher quality, requires deployment.

Both of these are for continuous search spaces only — spaces with no enumerable set of valid points (a learning rate, a hidden-layer-size range). That's their whole reason for existing: with nothing to enumerate, some finite set of points has to be invented before a GP can be asked anything, and n_probe_points controls how many.

If you already have a real, finite, enumerable set of candidates — a product catalog, an ad pool, embeddings for a fixed set of items — don't route it through either suggest_fn. Call call_modal_api directly with your real vectors as candidates. Going through modal_suggest/DimSpec in that case means inventing random points, asking the GP to pick among the fakes, then snapping the result back to the nearest real item afterward — an approximation of an approximation that buys you nothing when the real candidates were available to hand the GP directly.


Quickstart

pip install quantecarlo
pip install optunahub

In-process GP — no server required

import optuna
import optunahub
from functools import partial
from quantecarlo import DimSpec, fantasize_suggest

search_space = [
    DimSpec(name="x", type="float", low=-5.0, high=5.0),
    DimSpec(name="y", type="float", low=-5.0, high=5.0),
]

module = optunahub.load_module("package/samplers/batch_sampler")
BatchSampler = module.BatchSampler

sampler = BatchSampler(
    search_space=search_space,
    suggest_fn=partial(fantasize_suggest, direction="minimize"),
    q=4,
    n_startup_trials=8,
)

def objective(trial):
    x = trial.suggest_float("x", -5.0, 5.0)
    y = trial.suggest_float("y", -5.0, 5.0)
    return (x - 1.3) ** 2 + (y + 0.7) ** 2

study = optuna.create_study(direction="minimize", sampler=sampler)
study.optimize(objective, n_trials=40)
print(study.best_params)

Remote GP — Modal endpoint

from quantecarlo import DimSpec, modal_suggest

sampler = BatchSampler(
    search_space=search_space,
    suggest_fn=partial(modal_suggest, direction="minimize",
                       api_url="https://markshipman4273--bo-gp-service-gp-suggest.modal.run"),
    q=4,
    n_startup_trials=8,
)

Why ask-tell instead of study.optimize?

The ask-tell loop makes batching explicit and correct. With study.optimize(n_jobs=q), each worker calls the sampler independently — no worker knows what the other q-1 workers are about to try. Suggestions cluster.

The ask-tell pattern fixes this: all q asks happen before any evaluation. The first ask fires one API call that selects q jointly diverse candidates; asks 2 through q pop from a local cache. This is what makes joint q-EI meaningful in practice.

from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(max_workers=Q) as executor:
    for _ in range(N_ITERATIONS):
        trials = [study.ask() for _ in range(Q)]          # fills cache on ask #1
        futures = {executor.submit(objective, t): t for t in trials}
        for future, trial in futures.items():
            study.tell(trial, future.result())

See demos/demo.py for the full working example.


Reference

DimSpec

Describes one dimension of the search space.

Field Type Description
name str Must match the suggest_* call in your objective.
type "float" | "int" Continuous float or integer (snapped on decode).
low float Lower bound (inclusive).
high float Upper bound (inclusive).
log bool Log-uniform sampling. Default False.
step float | None Grid step for int dims. Default 1.

modal_suggest

modal_suggest(X, y, search_space, q, *, direction="minimize", api_url, n_probe_points=512,
              n_candidate_batches=None, train_steps=60, lr=0.1, xi=0.01,
              mode="production", seed=None, timeout=120.0, **tuning)

Invents n_probe_points random continuous points from search_space's bounds, sends them with X/y to the Modal GP endpoint, returns the highest q-EI batch. Bind parameters with functools.partial before passing to BatchSampler.

Only use this for a genuinely continuous search_space. If you have a real enumerable candidate pool, skip this function and call call_modal_api directly — see the note at the top of this README.

Parameter Default Description
direction "minimize" Must match the Optuna study direction.
api_url (hosted) Modal GP endpoint URL.
n_probe_points 512 Random continuous points invented per call and sent as the GP's candidate pool. Meaningless once you're calling call_modal_api with real points — there's nothing left to invent.
n_candidate_batches n_probe_points Server's n_batches: how many size-q index-combinations of that pool reach the joint q-EI stage (see Server-side tuning). Independent of n_probe_points — pass both explicitly to decouple them.
train_steps 60 Adam steps for GP kernel optimisation.
lr 0.1 Adam learning rate.
xi 0.01 EI exploration bonus.
mode "production" "debug" returns full posterior arrays.
seed None Random seed for the invented candidate pool.
timeout 120.0 HTTP timeout in seconds.
**tuning Any server-side tuning field, forwarded verbatim.

call_modal_api

call_modal_api(api_url, X, y, candidates, q=2, n_batches=512, train_steps=60,
                lr=0.1, xi=0.01, mode="production", timeout=120.0, **tuning)

The raw Modal HTTP client modal_suggest is built on — and the function to call directly whenever you have a real, materialized set of candidates (an embedded product catalog, an ad pool, any finite list you can turn into vectors). No DimSpec, no BatchSampler, no invented points: candidates is exactly the array you pass, and each returned index is a real index into it.

import numpy as np
from quantecarlo import call_modal_api

# X/y: your observed points and their scores so far (higher = better; negate first if minimizing)
# candidates: the real, unclaimed pool — e.g. embeddings for products/ads not yet tried
picks = call_modal_api(api_url, X, y, candidates, q=4)
for p in picks:
    real_item = candidates[p["index"]]   # already a real candidate — no snapping needed
Parameter Default Description
api_url (required) Modal GP endpoint URL.
X (required) Observed points, shape (n_obs, n_dims).
y (required) Observed scores, higher = better. Negate first for minimisation.
candidates (required) Discrete candidate pool to select from, shape (n_cands, n_dims) — your real items.
q 2 Number of candidates to return.
n_batches 512 Size-q index-combinations of candidates scored with joint q-EI before the best one is returned — the survivors of the server's prefilter (see Server-side tuning).
train_steps 60 Adam steps for GP kernel optimisation.
lr 0.1 Adam learning rate.
xi 0.01 EI exploration bonus.
mode "production" "debug" returns full posterior arrays.
timeout 120.0 HTTP timeout in seconds.
**tuning Any server-side tuning field, forwarded verbatim.

Returns list[dict], each with index (int, into candidates), x (the candidate vector), mu (GP posterior mean), sigma (GP posterior std).

See demos/demo9.py's run_arm_qei for a worked ask-tell loop built directly on this function, with no Optuna/BatchSampler involved at all.

Rules for X / y / candidates

  1. X and candidates must be in the same coordinate space (same columns, same meaning). Any numeric embedding works; the service rescales internally.
  2. y is maximised. For a loss / error / cost, negate it before sending.
  3. y needs no scaling or transform of any kind. Send raw values; the server rank-transforms.
  4. candidates is the complete menu: the response only ever contains members of it. Include already-evaluated points only if re-evaluating them is acceptable.
  5. Around 5 rows of X is the practical minimum for useful suggestions; fewer is allowed.

One call per round, not one per candidate. Nothing is stored server-side; every request is self-contained.

Preprocessing: reduce the dimensionality first

The service fits a model with one lengthscale per column of X. It needs many more rows than columns to learn them. Sending 256- or 1536-dim embeddings with 20 evaluated rows is under-determined and gives poor suggestions — measured, not theoretical: raw 1536-dim embeddings hit the curse of dimensionality in calibration runs.

So: project client-side before the call, and send the projected vectors.

  • Fit PCA on the pooled rows — evaluated points and candidates together — and project both with the same fit. Refit every call; the basis should follow the current pool. Keep the full embeddings in your own storage.
  • Pick k from your row count, not from the embedding size. With ~20 evaluated rows use 8–16 components; go higher only as rows accumulate. PCA cannot return more components than pooled rows anyway.
  • Existing consumers use k = 64 (text+image) / 32 (text-only) with hundreds of rows; that is a rule of thumb, never swept.
  • PCA is unsupervised: it keeps the high-variance axes, which are not necessarily the ones that predict y. With very few rows a supervised projection (PLS on y) may do better. Untested; flagged, not recommended.
from sklearn.decomposition import PCA
import numpy as np

pool = np.vstack([X_full, cand_full])                # all rows, full dims
k = min(16, len(pool))
pca = PCA(n_components=k, random_state=0).fit(pool)
X, candidates = pca.transform(X_full), pca.transform(cand_full)

Server-side tuning

All call_modal_api* functions and modal_suggest accept these as extra keyword arguments and forward them verbatim. Leave them out unless told otherwise — the server defaults apply and the payload never carries a value you didn't set. Unknown names raise TypeError client-side.

Field Server default Meaning
n_prefilter 10000 Distinct size-q index-batches drawn from the pool (every combination when C(n_cands, q) is smaller).
ei_direct_max 40000 When n_sampled × q exceeds this, every sampled batch is ranked by q-PI first and only the top n_batches go on to q-EI. Below it, q-EI scores everything sampled.
orthant_mode "auto" Orthant-probability approximation inside q-EI. "legacy" is the pre-2026-09 path.
order 1 "auto" only: 0 drops the Plackett correction — faster, less accurate.
gh_nodes 27 "auto" only: Gauss–Hermite nodes for the main integral.
gh_nodes_corr 5 "auto" only: nodes for the correction (order=1).

call_modal_api_multioutput / call_modal_api_composite

Same contract, for candidates from two related sources that should share one model (e.g. two ad platforms). call_modal_api_multioutput adds d_train / d_cands (output index 0/1 per row) and rho (cross-output correlation, default 0.5) to the X/candidates form. call_modal_api_composite is the one-row-per-item alternative for text+optional-image inputs: text, image, has_image (and _candidates counterparts) replace X/candidates; rows without an image send any placeholder vector with has_image=0. Both return the same list[dict] as call_modal_api. See the docstrings in quantecarlo/_modal_api.py.

fantasize_suggest

fantasize_suggest(X, y, search_space, q, direction="minimize", n_candidates=512,
                  noise=1e-3, xi=0.01, seed=None)

In-process RBF GP with sequential kriging (fantasization). Picks one candidate per GP fit, then fantasizes its outcome as the posterior mean before the next pick — so the batch spreads across the space without a remote call.

Parameter Default Description
direction "minimize" Must match the Optuna study direction.
n_candidates 512 Random candidates evaluated per GP call.
noise 1e-3 GP observation noise variance.
xi 0.01 EI exploration bonus.
seed None Random seed for the candidate pool.

Why q-EI instead of just adding more threads?

Running study.optimize(n_jobs=q) with a standard sampler (TPE, random) parallelises evaluation but each worker samples independently — it has no visibility into what the other q-1 workers are about to try. Candidates often cluster near the same local optimum.

q-EI scores the whole batch jointly. It computes the expected improvement of the best point in the batch over the current best, accounting for the full joint posterior covariance across all q candidates. The algorithm naturally diversifies: a second candidate near an already-selected point contributes little to the joint maximum, so the batch spreads across promising but distinct regions.

Each batch of q trials carries more information than q independently-drawn trials. You reach good solutions in fewer total evaluations — which matters when each evaluation is expensive (a training run, an experiment, a simulation).

Metadata

Release files for quantecarlo 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for quantecarlo 0.5.0
File Size Uploaded Provenance
quantecarlo-0.5.0.tar.gz 21.5 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for quantecarlo 0.5.0
File Interpreter ABI Platform
quantecarlo-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 36.3 kB

Release files / quantecarlo-0.5.0.tar.gz

Download URL quantecarlo-0.5.0.tar.gz
Size 21.5 kB
Tags Source
SHA-256 checksum
How to use checksums
64f2c166e9221577a8f46f163f7556dce76b00f05f7eb8c998d84620b80fe047
BLAKE2b-256 checksum
How to use checksums
b93fd67a66b88097243702621d8e9feb4b7aa8d37f524d5999a6099509fab43c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.9

Release files / quantecarlo-0.5.0-py3-none-any.whl

Download URL quantecarlo-0.5.0-py3-none-any.whl
Size 14.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e6c61dd907de2e141d493d45917f057dc799615e4aad7a5dab76faa8c629a105
BLAKE2b-256 checksum
How to use checksums
2d2f3c0a4f944ef5cd03aa49cd5f2e58f0487d9357a2ca035d2fe26d5bbed9af
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.9

Security for quantecarlo 0.5.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page