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
Xandcandidatesmust be in the same coordinate space (same columns, same meaning). Any numeric embedding works; the service rescales internally.yis maximised. For a loss / error / cost, negate it before sending.yneeds no scaling or transform of any kind. Send raw values; the server rank-transforms.candidatesis the complete menu: the response only ever contains members of it. Include already-evaluated points only if re-evaluating them is acceptable.- Around 5 rows of
Xis 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
kfrom 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 ony) 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)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| quantecarlo-0.5.0.tar.gz | 21.5 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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.