quantecarlo 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
quantecarlo/__init__.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# quantecarlo/bo_sampler.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
import warnings
|
|
9
|
+
from collections import deque
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import optuna
|
|
14
|
+
from optuna.distributions import (
|
|
15
|
+
BaseDistribution,
|
|
16
|
+
FloatDistribution,
|
|
17
|
+
IntDistribution,
|
|
18
|
+
)
|
|
19
|
+
from optuna.samplers import BaseSampler, RandomSampler
|
|
20
|
+
from optuna.trial import TrialState
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class DimSpec:
|
|
25
|
+
"""Describes one dimension of the search space."""
|
|
26
|
+
name: str
|
|
27
|
+
type: str # "float" | "int" (categorical deferred)
|
|
28
|
+
low: float
|
|
29
|
+
high: float
|
|
30
|
+
log: bool = False
|
|
31
|
+
step: float | None = None # grid step for int dims (default 1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class qEISampler(BaseSampler):
|
|
35
|
+
"""Optuna sampler that outsources GP fitting and q-EI scoring to a remote endpoint.
|
|
36
|
+
|
|
37
|
+
Fills a local cache with q suggestions on the first ask after the cache empties,
|
|
38
|
+
then hands them out one at a time. Falls back to random sampling during startup
|
|
39
|
+
and if the API call fails.
|
|
40
|
+
|
|
41
|
+
Thread-safety: a single threading.Lock ensures only one API call fires per batch
|
|
42
|
+
even when study.optimize(n_jobs=q) drives concurrent sample_relative calls.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
search_space: list[DimSpec],
|
|
48
|
+
api_url: str = "https://markshipman4273--bo-gp-service-gp-suggest.modal.run",
|
|
49
|
+
n_startup_trials: int = 8,
|
|
50
|
+
q: int = 4,
|
|
51
|
+
n_candidates: int = 512,
|
|
52
|
+
train_steps: int = 60,
|
|
53
|
+
lr: float = 0.1,
|
|
54
|
+
xi: float = 0.01,
|
|
55
|
+
mode: str = "production",
|
|
56
|
+
seed: int | None = None,
|
|
57
|
+
timeout: float = 120.0,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._api_url = api_url
|
|
60
|
+
self._search_space = search_space
|
|
61
|
+
self._n_startup_trials = n_startup_trials
|
|
62
|
+
self._q = q
|
|
63
|
+
self._n_candidates = n_candidates
|
|
64
|
+
self._train_steps = train_steps
|
|
65
|
+
self._lr = lr
|
|
66
|
+
self._xi = xi
|
|
67
|
+
self._mode = mode
|
|
68
|
+
self._timeout = timeout
|
|
69
|
+
self._independent_sampler = RandomSampler(seed=seed)
|
|
70
|
+
self._pending: deque[dict[str, Any]] = deque()
|
|
71
|
+
self._lock = threading.Lock()
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# BaseSampler interface
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def infer_relative_search_space(
|
|
78
|
+
self,
|
|
79
|
+
study: optuna.Study,
|
|
80
|
+
trial: optuna.trial.FrozenTrial,
|
|
81
|
+
) -> dict[str, BaseDistribution]:
|
|
82
|
+
# Use the explicit construction-time space rather than intersection_search_space
|
|
83
|
+
# so the sampler works immediately without waiting for multiple param-overlapping
|
|
84
|
+
# trials to accumulate.
|
|
85
|
+
result: dict[str, BaseDistribution] = {}
|
|
86
|
+
for dim in self._search_space:
|
|
87
|
+
if dim.type == "float":
|
|
88
|
+
result[dim.name] = FloatDistribution(
|
|
89
|
+
dim.low, dim.high, log=dim.log, step=dim.step
|
|
90
|
+
)
|
|
91
|
+
elif dim.type == "int":
|
|
92
|
+
result[dim.name] = IntDistribution(
|
|
93
|
+
int(dim.low),
|
|
94
|
+
int(dim.high),
|
|
95
|
+
log=dim.log,
|
|
96
|
+
step=int(dim.step) if dim.step is not None else 1,
|
|
97
|
+
)
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
def sample_relative(
|
|
101
|
+
self,
|
|
102
|
+
study: optuna.Study,
|
|
103
|
+
trial: optuna.trial.FrozenTrial,
|
|
104
|
+
search_space: dict[str, BaseDistribution],
|
|
105
|
+
) -> dict[str, Any]:
|
|
106
|
+
with self._lock:
|
|
107
|
+
# Serve from cache first — no API call needed.
|
|
108
|
+
if self._pending:
|
|
109
|
+
return self._pending.popleft()
|
|
110
|
+
|
|
111
|
+
# Cache empty: check whether we have enough data to fit the GP.
|
|
112
|
+
complete_trials = study.get_trials(
|
|
113
|
+
deepcopy=False, states=(TrialState.COMPLETE,)
|
|
114
|
+
)
|
|
115
|
+
if len(complete_trials) < self._n_startup_trials:
|
|
116
|
+
return {} # → Optuna falls back to sample_independent (random)
|
|
117
|
+
|
|
118
|
+
param_names = [dim.name for dim in self._search_space]
|
|
119
|
+
usable = [
|
|
120
|
+
t for t in complete_trials
|
|
121
|
+
if all(n in t.params for n in param_names)
|
|
122
|
+
]
|
|
123
|
+
if len(usable) < self._n_startup_trials:
|
|
124
|
+
return {}
|
|
125
|
+
|
|
126
|
+
# Build the observation matrices and call the remote API.
|
|
127
|
+
X = [[float(t.params[n]) for n in param_names] for t in usable]
|
|
128
|
+
# q-EI maximises, so negate trial values: minimisation targets become
|
|
129
|
+
# maximisation targets (lower error → higher negated value → q-EI seeks it).
|
|
130
|
+
y = [-float(t.value) for t in usable]
|
|
131
|
+
|
|
132
|
+
payload = {
|
|
133
|
+
"X": X,
|
|
134
|
+
"y": y,
|
|
135
|
+
"search_space": [asdict(dim) for dim in self._search_space],
|
|
136
|
+
"q": self._q,
|
|
137
|
+
"n_candidates": self._n_candidates,
|
|
138
|
+
"train_steps": self._train_steps,
|
|
139
|
+
"lr": self._lr,
|
|
140
|
+
"xi": self._xi,
|
|
141
|
+
"mode": self._mode,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
data = self._post(payload)
|
|
146
|
+
if self._mode == "debug" and data.get("ei_all") is not None:
|
|
147
|
+
ei_all = data["ei_all"]
|
|
148
|
+
display = [round(v, 6) if v is not None else "NaN" for v in ei_all]
|
|
149
|
+
print(f"\n[debug] ei_all ({len(ei_all)} batches): {display}")
|
|
150
|
+
valid = [v for v in ei_all if v is not None]
|
|
151
|
+
if valid:
|
|
152
|
+
print(f"[debug] max ei : {max(valid):.6f} winning batch ei_score: {data.get('ei_scores')}")
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
warnings.warn(
|
|
155
|
+
f"qEISampler: API call failed ({exc}), falling back to random."
|
|
156
|
+
)
|
|
157
|
+
return {}
|
|
158
|
+
|
|
159
|
+
# Populate cache; snap int dims to Python int so suggest_int is happy.
|
|
160
|
+
for candidate in data["candidates"]:
|
|
161
|
+
params: dict[str, Any] = {}
|
|
162
|
+
for i, dim in enumerate(self._search_space):
|
|
163
|
+
val = float(candidate["x"][i])
|
|
164
|
+
if dim.type == "int":
|
|
165
|
+
val = int(round(val)) # type: ignore[assignment]
|
|
166
|
+
params[dim.name] = val
|
|
167
|
+
self._pending.append(params)
|
|
168
|
+
|
|
169
|
+
return self._pending.popleft() if self._pending else {}
|
|
170
|
+
|
|
171
|
+
def sample_independent(
|
|
172
|
+
self,
|
|
173
|
+
study: optuna.Study,
|
|
174
|
+
trial: optuna.trial.FrozenTrial,
|
|
175
|
+
param_name: str,
|
|
176
|
+
param_distribution: BaseDistribution,
|
|
177
|
+
) -> Any:
|
|
178
|
+
return self._independent_sampler.sample_independent(
|
|
179
|
+
study, trial, param_name, param_distribution
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# Internal
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _post(self, payload: dict) -> dict:
|
|
187
|
+
body = json.dumps(payload).encode("utf-8")
|
|
188
|
+
req = urllib.request.Request(
|
|
189
|
+
self._api_url,
|
|
190
|
+
data=body,
|
|
191
|
+
headers={"Content-Type": "application/json"},
|
|
192
|
+
method="POST",
|
|
193
|
+
)
|
|
194
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
195
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quantecarlo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Batch Bayesian optimization sampler (q-EI) for Optuna, backed by a remote GP service
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Intended Audience :: Science/Research
|
|
8
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: optuna>=3.0
|
|
12
|
+
|
|
13
|
+
# quantecarlo
|
|
14
|
+
|
|
15
|
+
Batch Bayesian optimization for [Optuna](https://optuna.org) using **q-Expected Improvement (q-EI)**. Drop in one sampler, point it at a hosted GP endpoint, and get a batch of `q` well-chosen candidates back per iteration instead of one at a time.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install quantecarlo
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import optuna
|
|
27
|
+
from quantecarlo import DimSpec, qEISampler
|
|
28
|
+
|
|
29
|
+
# Define what you're optimizing — here, a simple 2-D quadratic.
|
|
30
|
+
# In practice this is your training run, simulation, or experiment.
|
|
31
|
+
def objective(trial):
|
|
32
|
+
x = trial.suggest_float("x", -5.0, 5.0)
|
|
33
|
+
y = trial.suggest_float("y", -5.0, 5.0)
|
|
34
|
+
return (x - 1.3) ** 2 + (y + 0.7) ** 2 # minimum at (1.3, -0.7)
|
|
35
|
+
|
|
36
|
+
# The DimSpec names and bounds must match the suggest_* calls above.
|
|
37
|
+
search_space = [
|
|
38
|
+
DimSpec(name="x", type="float", low=-5.0, high=5.0),
|
|
39
|
+
DimSpec(name="y", type="float", low=-5.0, high=5.0),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
sampler = qEISampler(
|
|
43
|
+
search_space=search_space,
|
|
44
|
+
q=4,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
study = optuna.create_study(direction="minimize", sampler=sampler)
|
|
48
|
+
study.optimize(objective, n_trials=40, n_jobs=4)
|
|
49
|
+
print(study.best_params)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
See `demo.py` for a full ask-tell example using an MLP on the breast-cancer dataset, with explicit batching and a per-iteration progress table.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## What's happening under the hood (you don't need to touch any of this)
|
|
57
|
+
|
|
58
|
+
Each time the local suggestion cache runs dry, `qEISampler` POSTs your observed `(X, y)` pairs to a remote GP service. That service:
|
|
59
|
+
|
|
60
|
+
1. **Normalises** each parameter to [0, 1] (log-scale for `log=True` dims).
|
|
61
|
+
2. **Rank-transforms** `y` to standard-normal via the Probability Integral Transform — so the GP always sees well-behaved Gaussian targets regardless of the shape of your objective's distribution.
|
|
62
|
+
3. **Fits an ExactGP** (Matérn-5/2 ARD kernel) on a GPU via Adam on the marginal log-likelihood.
|
|
63
|
+
4. **Draws `n_candidates` random candidate batches** of size `q` and scores each batch jointly with q-EI.
|
|
64
|
+
5. **Returns the highest-scoring batch** decoded back to your original parameter scale. Int dims are snapped to the nearest integer.
|
|
65
|
+
|
|
66
|
+
The sampler then hands out one candidate per `study.ask()` call from the local cache. The next API call doesn't fire until the cache is exhausted — so `q` threads share a single round-trip.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Parameters
|
|
71
|
+
|
|
72
|
+
### `DimSpec`
|
|
73
|
+
|
|
74
|
+
Describes one dimension of your search space.
|
|
75
|
+
|
|
76
|
+
| Field | Type | Description |
|
|
77
|
+
|--------|-------------------------|-------------|
|
|
78
|
+
| `name` | `str` | Must match the corresponding `suggest_*` call in your objective. |
|
|
79
|
+
| `type` | `"float"` \| `"int"` | Continuous float or integer. Int dims are snapped on decode. |
|
|
80
|
+
| `low` | `float` | Lower bound (inclusive). |
|
|
81
|
+
| `high` | `float` | Upper bound (inclusive). |
|
|
82
|
+
| `log` | `bool` | Log-uniform sampling. Use for parameters that span orders of magnitude (learning rates, weight decay). Default `False`. |
|
|
83
|
+
| `step` | `float \| None` | Grid step for `int` dims. Default `1`. |
|
|
84
|
+
|
|
85
|
+
Categorical dimensions are not yet supported.
|
|
86
|
+
|
|
87
|
+
### `qEISampler`
|
|
88
|
+
|
|
89
|
+
| Parameter | Default | Description |
|
|
90
|
+
|-------------------|----------------|-------------|
|
|
91
|
+
| `search_space` | — | List of `DimSpec`, one per hyperparameter. |
|
|
92
|
+
| `api_url` | *(hosted)* | GP endpoint URL. Override only if self-hosting. |
|
|
93
|
+
| `n_startup_trials`| `8` | Number of random trials before the GP is used. Too few observations make GP fitting unreliable. |
|
|
94
|
+
| `q` | `4` | Batch size. Set `n_jobs=q` in `study.optimize` to evaluate the batch in parallel. |
|
|
95
|
+
| `n_candidates` | `512` | Random candidate batches scored per API call. Larger = better coverage; diminishing returns above ~1024 for most spaces. |
|
|
96
|
+
| `train_steps` | `60` | Adam steps for GP kernel hyperparameter optimisation. Increase for tighter fits on noisy objectives. |
|
|
97
|
+
| `lr` | `0.1` | Adam learning rate for GP training. |
|
|
98
|
+
| `xi` | `0.01` | EI exploration bonus. Larger values bias toward uncertain regions; smaller values exploit the current best. |
|
|
99
|
+
| `mode` | `"production"` | `"debug"` returns the full GP posterior surface in the API response — useful for diagnostics. |
|
|
100
|
+
| `seed` | `None` | Random seed for the fallback random sampler. |
|
|
101
|
+
| `timeout` | `120.0` | HTTP timeout in seconds for the API call. |
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Why q-EI instead of just adding more threads?
|
|
106
|
+
|
|
107
|
+
Running `study.optimize(n_jobs=q)` with a standard sampler (TPE, random) does parallelize objective evaluation, but each worker samples **independently** — it has no idea what the other `q-1` workers are about to try. You often end up with a batch where several candidates cluster near the same local optimum.
|
|
108
|
+
|
|
109
|
+
**q-EI scores the whole batch jointly.** It computes the expected improvement of the *best point in the batch* over the current best, taking into account the full joint posterior covariance across the `q` candidates. The optimizer naturally diversifies: a second candidate near an already-selected point contributes little to the joint maximum, so the algorithm spreads the batch across promising but distinct regions.
|
|
110
|
+
|
|
111
|
+
In practice this means each batch of `q` trials carries more information than `q` independently-drawn trials. You cover the space more efficiently and tend to reach good solutions in fewer total function evaluations — which matters when each evaluation is expensive (a training run, an experiment, a simulation).
|
|
112
|
+
|
|
113
|
+
The cost is one API call per batch (a few seconds for a warm GP endpoint) in exchange for a smarter set of `q` candidates. That tradeoff is almost always worth it when objective evaluations take more than a minute.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
quantecarlo/__init__.py,sha256=dfGWRCi6978K4yNNWP190EjU2l35RZkvqAq8cIklNoU,92
|
|
2
|
+
quantecarlo/bo_sampler.py,sha256=pdJ0_ZwgN4DVzhWbN0uzQ2INyQYjW_Ico-D-x3Cm32Y,7234
|
|
3
|
+
quantecarlo-0.1.0.dist-info/METADATA,sha256=ZHP8fMSY1Z4igsnsZsdiz2c9QdtmbLy5zwPLBd_1tbM,6232
|
|
4
|
+
quantecarlo-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
quantecarlo-0.1.0.dist-info/top_level.txt,sha256=9Uqqo4mnRkD3qQa9kvNqv_ITuyHG1Ha7uLBrqdhFHy8,12
|
|
6
|
+
quantecarlo-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
quantecarlo
|