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.
@@ -0,0 +1,3 @@
1
+ from quantecarlo.bo_sampler import DimSpec, qEISampler
2
+
3
+ __all__ = ["DimSpec", "qEISampler"]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ quantecarlo