quantecarlo 0.1.2__tar.gz → 0.3.0__tar.gz

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,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: quantecarlo
3
+ Version: 0.3.0
4
+ Summary: Batch Bayesian optimization sampler (q-EI) for Optuna, backed by a remote GP service
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://quantecarlo.com
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Intended Audience :: Science/Research
9
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: optuna>=3.0
13
+ Requires-Dist: numpy>=1.24
14
+ Requires-Dist: scipy>=1.10
15
+
16
+ # quantecarlo
17
+
18
+ Batch Bayesian optimization for [Optuna](https://optuna.org) using **q-Expected Improvement (q-EI)**. Two drop-in `suggest_fn` implementations for the optunahub [`BatchSampler`](https://hub.optuna.org/samplers/batch_sampler/):
19
+
20
+ | Function | Description |
21
+ |---|---|
22
+ | `fantasize_suggest` | Self-contained in-process GP (numpy/scipy). No server required. |
23
+ | `modal_suggest` | Delegates to a hosted GPU GP endpoint (Modal). Higher quality, requires deployment. |
24
+
25
+ ---
26
+
27
+ ## Quickstart
28
+
29
+ ```bash
30
+ pip install quantecarlo
31
+ pip install optunahub
32
+ ```
33
+
34
+ ### In-process GP — no server required
35
+
36
+ ```python
37
+ import optuna
38
+ import optunahub
39
+ from functools import partial
40
+ from quantecarlo import DimSpec, fantasize_suggest
41
+
42
+ search_space = [
43
+ DimSpec(name="x", type="float", low=-5.0, high=5.0),
44
+ DimSpec(name="y", type="float", low=-5.0, high=5.0),
45
+ ]
46
+
47
+ module = optunahub.load_module("package/samplers/batch_sampler")
48
+ BatchSampler = module.BatchSampler
49
+
50
+ sampler = BatchSampler(
51
+ search_space=search_space,
52
+ suggest_fn=partial(fantasize_suggest, direction="minimize"),
53
+ q=4,
54
+ n_startup_trials=8,
55
+ )
56
+
57
+ def objective(trial):
58
+ x = trial.suggest_float("x", -5.0, 5.0)
59
+ y = trial.suggest_float("y", -5.0, 5.0)
60
+ return (x - 1.3) ** 2 + (y + 0.7) ** 2
61
+
62
+ study = optuna.create_study(direction="minimize", sampler=sampler)
63
+ study.optimize(objective, n_trials=40)
64
+ print(study.best_params)
65
+ ```
66
+
67
+ ### Remote GP — Modal endpoint
68
+
69
+ ```python
70
+ from quantecarlo import DimSpec, modal_suggest
71
+
72
+ sampler = BatchSampler(
73
+ search_space=search_space,
74
+ suggest_fn=partial(modal_suggest, direction="minimize",
75
+ api_url="https://markshipman4273--bo-gp-service-gp-suggest.modal.run"),
76
+ q=4,
77
+ n_startup_trials=8,
78
+ )
79
+ ```
80
+
81
+ ### Why ask-tell instead of `study.optimize`?
82
+
83
+ 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.
84
+
85
+ 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.
86
+
87
+ ```python
88
+ from concurrent.futures import ThreadPoolExecutor
89
+
90
+ with ThreadPoolExecutor(max_workers=Q) as executor:
91
+ for _ in range(N_ITERATIONS):
92
+ trials = [study.ask() for _ in range(Q)] # fills cache on ask #1
93
+ futures = {executor.submit(objective, t): t for t in trials}
94
+ for future, trial in futures.items():
95
+ study.tell(trial, future.result())
96
+ ```
97
+
98
+ See `demos/demo.py` for the full working example.
99
+
100
+ ---
101
+
102
+ ## Reference
103
+
104
+ ### `DimSpec`
105
+
106
+ Describes one dimension of the search space.
107
+
108
+ | Field | Type | Description |
109
+ |--------|-----------------------|-------------|
110
+ | `name` | `str` | Must match the `suggest_*` call in your objective. |
111
+ | `type` | `"float"` \| `"int"` | Continuous float or integer (snapped on decode). |
112
+ | `low` | `float` | Lower bound (inclusive). |
113
+ | `high` | `float` | Upper bound (inclusive). |
114
+ | `log` | `bool` | Log-uniform sampling. Default `False`. |
115
+ | `step` | `float \| None` | Grid step for `int` dims. Default `1`. |
116
+
117
+ ### `modal_suggest`
118
+
119
+ ```python
120
+ modal_suggest(X, y, search_space, q, *, direction="minimize", api_url, n_candidates=512,
121
+ train_steps=60, lr=0.1, xi=0.01, mode="production", seed=None, timeout=120.0)
122
+ ```
123
+
124
+ Sends `X`, `y`, and a random candidate pool to the Modal GP endpoint; returns the highest q-EI batch. Bind parameters with `functools.partial` before passing to `BatchSampler`.
125
+
126
+ | Parameter | Default | Description |
127
+ |----------------|------------------|-------------|
128
+ | `direction` | `"minimize"` | Must match the Optuna study direction. |
129
+ | `api_url` | *(hosted)* | Modal GP endpoint URL. |
130
+ | `n_candidates` | `512` | Random candidates scored per call. |
131
+ | `train_steps` | `60` | Adam steps for GP kernel optimisation. |
132
+ | `lr` | `0.1` | Adam learning rate. |
133
+ | `xi` | `0.01` | EI exploration bonus. |
134
+ | `mode` | `"production"` | `"debug"` returns full posterior arrays. |
135
+ | `seed` | `None` | Random seed for the candidate pool. |
136
+ | `timeout` | `120.0` | HTTP timeout in seconds. |
137
+
138
+ ### `fantasize_suggest`
139
+
140
+ ```python
141
+ fantasize_suggest(X, y, search_space, q, direction="minimize", n_candidates=512,
142
+ noise=1e-3, xi=0.01, seed=None)
143
+ ```
144
+
145
+ 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.
146
+
147
+ | Parameter | Default | Description |
148
+ |----------------|--------------|-------------|
149
+ | `direction` | `"minimize"` | Must match the Optuna study direction. |
150
+ | `n_candidates` | `512` | Random candidates evaluated per GP call. |
151
+ | `noise` | `1e-3` | GP observation noise variance. |
152
+ | `xi` | `0.01` | EI exploration bonus. |
153
+ | `seed` | `None` | Random seed for the candidate pool. |
154
+
155
+ ---
156
+
157
+ ## Why q-EI instead of just adding more threads?
158
+
159
+ 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.
160
+
161
+ **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.
162
+
163
+ 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).
@@ -0,0 +1,148 @@
1
+ # quantecarlo
2
+
3
+ Batch Bayesian optimization for [Optuna](https://optuna.org) using **q-Expected Improvement (q-EI)**. Two drop-in `suggest_fn` implementations for the optunahub [`BatchSampler`](https://hub.optuna.org/samplers/batch_sampler/):
4
+
5
+ | Function | Description |
6
+ |---|---|
7
+ | `fantasize_suggest` | Self-contained in-process GP (numpy/scipy). No server required. |
8
+ | `modal_suggest` | Delegates to a hosted GPU GP endpoint (Modal). Higher quality, requires deployment. |
9
+
10
+ ---
11
+
12
+ ## Quickstart
13
+
14
+ ```bash
15
+ pip install quantecarlo
16
+ pip install optunahub
17
+ ```
18
+
19
+ ### In-process GP — no server required
20
+
21
+ ```python
22
+ import optuna
23
+ import optunahub
24
+ from functools import partial
25
+ from quantecarlo import DimSpec, fantasize_suggest
26
+
27
+ search_space = [
28
+ DimSpec(name="x", type="float", low=-5.0, high=5.0),
29
+ DimSpec(name="y", type="float", low=-5.0, high=5.0),
30
+ ]
31
+
32
+ module = optunahub.load_module("package/samplers/batch_sampler")
33
+ BatchSampler = module.BatchSampler
34
+
35
+ sampler = BatchSampler(
36
+ search_space=search_space,
37
+ suggest_fn=partial(fantasize_suggest, direction="minimize"),
38
+ q=4,
39
+ n_startup_trials=8,
40
+ )
41
+
42
+ def objective(trial):
43
+ x = trial.suggest_float("x", -5.0, 5.0)
44
+ y = trial.suggest_float("y", -5.0, 5.0)
45
+ return (x - 1.3) ** 2 + (y + 0.7) ** 2
46
+
47
+ study = optuna.create_study(direction="minimize", sampler=sampler)
48
+ study.optimize(objective, n_trials=40)
49
+ print(study.best_params)
50
+ ```
51
+
52
+ ### Remote GP — Modal endpoint
53
+
54
+ ```python
55
+ from quantecarlo import DimSpec, modal_suggest
56
+
57
+ sampler = BatchSampler(
58
+ search_space=search_space,
59
+ suggest_fn=partial(modal_suggest, direction="minimize",
60
+ api_url="https://markshipman4273--bo-gp-service-gp-suggest.modal.run"),
61
+ q=4,
62
+ n_startup_trials=8,
63
+ )
64
+ ```
65
+
66
+ ### Why ask-tell instead of `study.optimize`?
67
+
68
+ 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.
69
+
70
+ 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.
71
+
72
+ ```python
73
+ from concurrent.futures import ThreadPoolExecutor
74
+
75
+ with ThreadPoolExecutor(max_workers=Q) as executor:
76
+ for _ in range(N_ITERATIONS):
77
+ trials = [study.ask() for _ in range(Q)] # fills cache on ask #1
78
+ futures = {executor.submit(objective, t): t for t in trials}
79
+ for future, trial in futures.items():
80
+ study.tell(trial, future.result())
81
+ ```
82
+
83
+ See `demos/demo.py` for the full working example.
84
+
85
+ ---
86
+
87
+ ## Reference
88
+
89
+ ### `DimSpec`
90
+
91
+ Describes one dimension of the search space.
92
+
93
+ | Field | Type | Description |
94
+ |--------|-----------------------|-------------|
95
+ | `name` | `str` | Must match the `suggest_*` call in your objective. |
96
+ | `type` | `"float"` \| `"int"` | Continuous float or integer (snapped on decode). |
97
+ | `low` | `float` | Lower bound (inclusive). |
98
+ | `high` | `float` | Upper bound (inclusive). |
99
+ | `log` | `bool` | Log-uniform sampling. Default `False`. |
100
+ | `step` | `float \| None` | Grid step for `int` dims. Default `1`. |
101
+
102
+ ### `modal_suggest`
103
+
104
+ ```python
105
+ modal_suggest(X, y, search_space, q, *, direction="minimize", api_url, n_candidates=512,
106
+ train_steps=60, lr=0.1, xi=0.01, mode="production", seed=None, timeout=120.0)
107
+ ```
108
+
109
+ Sends `X`, `y`, and a random candidate pool to the Modal GP endpoint; returns the highest q-EI batch. Bind parameters with `functools.partial` before passing to `BatchSampler`.
110
+
111
+ | Parameter | Default | Description |
112
+ |----------------|------------------|-------------|
113
+ | `direction` | `"minimize"` | Must match the Optuna study direction. |
114
+ | `api_url` | *(hosted)* | Modal GP endpoint URL. |
115
+ | `n_candidates` | `512` | Random candidates scored per call. |
116
+ | `train_steps` | `60` | Adam steps for GP kernel optimisation. |
117
+ | `lr` | `0.1` | Adam learning rate. |
118
+ | `xi` | `0.01` | EI exploration bonus. |
119
+ | `mode` | `"production"` | `"debug"` returns full posterior arrays. |
120
+ | `seed` | `None` | Random seed for the candidate pool. |
121
+ | `timeout` | `120.0` | HTTP timeout in seconds. |
122
+
123
+ ### `fantasize_suggest`
124
+
125
+ ```python
126
+ fantasize_suggest(X, y, search_space, q, direction="minimize", n_candidates=512,
127
+ noise=1e-3, xi=0.01, seed=None)
128
+ ```
129
+
130
+ 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.
131
+
132
+ | Parameter | Default | Description |
133
+ |----------------|--------------|-------------|
134
+ | `direction` | `"minimize"` | Must match the Optuna study direction. |
135
+ | `n_candidates` | `512` | Random candidates evaluated per GP call. |
136
+ | `noise` | `1e-3` | GP observation noise variance. |
137
+ | `xi` | `0.01` | EI exploration bonus. |
138
+ | `seed` | `None` | Random seed for the candidate pool. |
139
+
140
+ ---
141
+
142
+ ## Why q-EI instead of just adding more threads?
143
+
144
+ 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.
145
+
146
+ **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.
147
+
148
+ 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).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "quantecarlo"
7
- version = "0.1.2"
7
+ version = "0.3.0"
8
8
  description = "Batch Bayesian optimization sampler (q-EI) for Optuna, backed by a remote GP service"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,11 @@
1
+ from quantecarlo.bo_sampler import DimSpec, modal_suggest
2
+ from quantecarlo._modal_api import call_modal_api, call_modal_api_multioutput
3
+ from quantecarlo._fantasize import fantasize_suggest
4
+
5
+ __all__ = [
6
+ "DimSpec",
7
+ "modal_suggest",
8
+ "call_modal_api",
9
+ "call_modal_api_multioutput",
10
+ "fantasize_suggest",
11
+ ]
@@ -17,6 +17,7 @@ def fantasize_suggest(
17
17
  y: list[float],
18
18
  search_space: list,
19
19
  q: int,
20
+ direction: str = "minimize",
20
21
  n_candidates: int = 512,
21
22
  noise: float = 1e-3,
22
23
  xi: float = 0.01,
@@ -34,11 +35,14 @@ def fantasize_suggest(
34
35
  X:
35
36
  Completed-trial parameter vectors, shape (n, d).
36
37
  y:
37
- Raw objective values lower is better (minimize convention).
38
+ Raw objective values. Pass in the same convention as the Optuna study
39
+ direction — lower is better for "minimize", higher is better for "maximize".
38
40
  search_space:
39
41
  DimSpec-like objects with .name, .type, .low, .high, .log, .step.
40
42
  q:
41
43
  Number of candidates to return.
44
+ direction:
45
+ "minimize" or "maximize" — must match the Optuna study direction.
42
46
  n_candidates:
43
47
  Random candidates evaluated per GP call.
44
48
  noise:
@@ -69,8 +73,10 @@ def fantasize_suggest(
69
73
  X_arr = np.array(X, dtype=float)
70
74
  X_unit = np.column_stack([_to_unit(X_arr[:, i], i) for i in range(d)])
71
75
 
72
- # Standardise y for GP numerical stability
76
+ # GP always works higher-is-better internally; negate for minimize studies.
73
77
  y_arr = np.array(y, dtype=float)
78
+ if direction == "minimize":
79
+ y_arr = -y_arr
74
80
  y_std = y_arr.std()
75
81
  if y_std < 1e-8:
76
82
  y_std = 1.0
@@ -111,10 +117,10 @@ def fantasize_suggest(
111
117
  var = np.maximum(1.0 - np.sum(v ** 2, axis=0), 1e-9)
112
118
  sigma = np.sqrt(var)
113
119
 
114
- # Expected Improvement (minimise)
115
- best_y = y_aug.min()
116
- z = (best_y - xi - mu) / sigma
117
- ei = (best_y - xi - mu) * norm.cdf(z) + sigma * norm.pdf(z)
120
+ # Expected Improvement (maximise — y_aug is already higher-is-better)
121
+ best_y = y_aug.max()
122
+ z = (mu - best_y - xi) / sigma
123
+ ei = (mu - best_y - xi) * norm.cdf(z) + sigma * norm.pdf(z)
118
124
 
119
125
  idx = int(np.argmax(ei))
120
126
  best_unit = cands[idx]
@@ -0,0 +1,152 @@
1
+ """Raw HTTP client for the Modal GP endpoint.
2
+
3
+ This is the single source of truth for the Modal API contract. Both
4
+ modal_suggest (Optuna/BatchSampler integration) and direct callers such as
5
+ meta-ads-demo import from here. When the API payload changes, update this
6
+ file and every consumer gets the change.
7
+
8
+ y convention: higher is better. Callers that use a minimise objective must
9
+ negate y before calling these functions.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import urllib.error
16
+ import urllib.request
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def _post(api_url: str, payload: dict, timeout: float) -> dict:
25
+ body = json.dumps(payload).encode("utf-8")
26
+ req = urllib.request.Request(
27
+ api_url,
28
+ data=body,
29
+ headers={"Content-Type": "application/json"},
30
+ method="POST",
31
+ )
32
+ try:
33
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
34
+ return json.loads(resp.read().decode("utf-8"))
35
+ except urllib.error.HTTPError as exc:
36
+ detail = exc.read().decode("utf-8", errors="replace")
37
+ raise urllib.error.HTTPError(
38
+ exc.url, exc.code, f"{exc.reason} — {detail}", exc.headers, None
39
+ ) from None
40
+
41
+
42
+ def call_modal_api(
43
+ api_url: str,
44
+ X: np.ndarray,
45
+ y: np.ndarray,
46
+ candidates: np.ndarray,
47
+ q: int = 2,
48
+ n_batches: int = 512,
49
+ train_steps: int = 100,
50
+ lr: float = 0.1,
51
+ xi: float = 0.01,
52
+ mode: str = "production",
53
+ timeout: float = 120.0,
54
+ ) -> list[dict[str, Any]]:
55
+ """POST to the Modal GP endpoint and return q candidate dicts.
56
+
57
+ y convention: higher is better. Pass scores directly for maximisation
58
+ objectives; negate first for minimisation.
59
+
60
+ X: observed points, shape (n_obs, n_dims)
61
+ y: observed scores, shape (n_obs,)
62
+ candidates: discrete candidate pool to select from, shape (n_cands, n_dims)
63
+
64
+ Each returned dict has:
65
+ "index" — int, index into candidates
66
+ "x" — np.ndarray shape (n_dims,), the selected candidate vector
67
+ "mu" — float | None, GP posterior mean
68
+ "sigma" — float | None, GP posterior std
69
+ """
70
+ payload: dict[str, Any] = {
71
+ "X": X.tolist(),
72
+ "y": y.tolist(),
73
+ "candidates": candidates.tolist(),
74
+ "q": q,
75
+ "n_batches": n_batches,
76
+ "train_steps": train_steps,
77
+ "lr": lr,
78
+ "xi": xi,
79
+ "mode": mode,
80
+ }
81
+ logger.debug(
82
+ "call_modal_api: POST %s (n_obs=%d, n_cands=%d, n_dims=%d, q=%d)",
83
+ api_url, len(y), len(candidates), candidates.shape[1], q,
84
+ )
85
+ data = _post(api_url, payload, timeout)
86
+ return [
87
+ {
88
+ "index": int(c["index"]),
89
+ "x": np.array(c["x"], dtype=np.float32),
90
+ "mu": c.get("mu"),
91
+ "sigma": c.get("sigma"),
92
+ }
93
+ for c in data["candidates"]
94
+ ]
95
+
96
+
97
+ def call_modal_api_multioutput(
98
+ api_url: str,
99
+ X: np.ndarray,
100
+ y: np.ndarray,
101
+ candidates: np.ndarray,
102
+ d_train: np.ndarray,
103
+ d_cands: np.ndarray,
104
+ rho: float = 0.5,
105
+ q: int = 2,
106
+ n_batches: int = 512,
107
+ train_steps: int = 100,
108
+ lr: float = 0.1,
109
+ xi: float = 0.01,
110
+ mode: str = "production",
111
+ timeout: float = 120.0,
112
+ ) -> list[dict[str, Any]]:
113
+ """POST to the Modal GP multioutput endpoint and return q candidate dicts.
114
+
115
+ Identical to call_modal_api but additionally sends d_train, d_cands, and
116
+ rho. The server branches to the multioutput GP when d and d_candidates are
117
+ present in the payload.
118
+
119
+ d_train: int array shape (n_obs,) — output index per training point (e.g. 0=Meta, 1=Google)
120
+ d_cands: int array shape (n_cands,) — output index per candidate
121
+ rho: cross-platform correlation in (-1, 1)
122
+
123
+ Return format is identical to call_modal_api.
124
+ """
125
+ payload: dict[str, Any] = {
126
+ "X": X.tolist(),
127
+ "y": y.tolist(),
128
+ "candidates": candidates.tolist(),
129
+ "d": d_train.tolist(),
130
+ "d_candidates": d_cands.tolist(),
131
+ "rho": float(rho),
132
+ "q": q,
133
+ "n_batches": n_batches,
134
+ "train_steps": train_steps,
135
+ "lr": lr,
136
+ "xi": xi,
137
+ "mode": mode,
138
+ }
139
+ logger.debug(
140
+ "call_modal_api_multioutput: POST %s (n_obs=%d, n_cands=%d, n_dims=%d, q=%d, rho=%.2f)",
141
+ api_url, len(y), len(candidates), candidates.shape[1], q, rho,
142
+ )
143
+ data = _post(api_url, payload, timeout)
144
+ return [
145
+ {
146
+ "index": int(c["index"]),
147
+ "x": np.array(c["x"], dtype=np.float32),
148
+ "mu": c.get("mu"),
149
+ "sigma": c.get("sigma"),
150
+ }
151
+ for c in data["candidates"]
152
+ ]
@@ -0,0 +1,92 @@
1
+ # quantecarlo/bo_sampler.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ from quantecarlo._modal_api import call_modal_api
10
+
11
+
12
+ @dataclass
13
+ class DimSpec:
14
+ """Describes one dimension of the search space."""
15
+ name: str
16
+ type: str # "float" | "int"
17
+ low: float
18
+ high: float
19
+ log: bool = False
20
+ step: float | None = None
21
+
22
+
23
+ def modal_suggest(
24
+ X: list[list[float]],
25
+ y: list[float],
26
+ search_space: list[DimSpec],
27
+ q: int,
28
+ *,
29
+ direction: str = "minimize",
30
+ api_url: str = "https://markshipman4273--bo-gp-service-gp-suggest.modal.run",
31
+ n_candidates: int = 512,
32
+ train_steps: int = 60,
33
+ lr: float = 0.1,
34
+ xi: float = 0.01,
35
+ mode: str = "production",
36
+ seed: int | None = None,
37
+ timeout: float = 120.0,
38
+ ) -> list[dict[str, Any]]:
39
+ """suggest_fn for BatchSampler that delegates to a remote Modal GP endpoint.
40
+
41
+ direction: "minimize" or "maximize" — must match the Optuna study direction.
42
+ BatchSampler passes raw study values; this function converts to the Modal API's
43
+ higher-is-better convention internally.
44
+
45
+ Bind extra parameters with functools.partial before passing to BatchSampler:
46
+
47
+ from functools import partial
48
+ from quantecarlo import modal_suggest, DimSpec
49
+ suggest = partial(modal_suggest, direction="minimize", api_url="https://...", n_candidates=1024)
50
+ sampler = BatchSampler(search_space=dims, suggest_fn=suggest, q=4)
51
+ """
52
+ rng = np.random.default_rng(seed)
53
+ candidates = np.array(_sample_candidates(search_space, n_candidates, rng), dtype=np.float32)
54
+ # Modal API is higher-is-better; negate y for minimize studies.
55
+ y_send = np.array([-v for v in y] if direction == "minimize" else list(y), dtype=np.float32)
56
+ X_arr = np.array(X, dtype=np.float32)
57
+
58
+ raw = call_modal_api(
59
+ api_url, X_arr, y_send, candidates,
60
+ q=q, n_batches=n_candidates, train_steps=train_steps,
61
+ lr=lr, xi=xi, mode=mode, timeout=timeout,
62
+ )
63
+
64
+ results: list[dict[str, Any]] = []
65
+ for item in raw:
66
+ params: dict[str, Any] = {}
67
+ for i, dim in enumerate(search_space):
68
+ val = float(item["x"][i])
69
+ if dim.type == "int":
70
+ val = int(round(val))
71
+ params[dim.name] = val
72
+ results.append(params)
73
+ return results
74
+
75
+
76
+ def _sample_candidates(
77
+ dims: list[DimSpec], n: int, rng: np.random.Generator
78
+ ) -> list[list[float]]:
79
+ candidates = []
80
+ for _ in range(n):
81
+ point = []
82
+ for dim in dims:
83
+ if dim.type == "float":
84
+ if dim.log:
85
+ val = float(np.exp(rng.uniform(np.log(dim.low), np.log(dim.high))))
86
+ else:
87
+ val = float(rng.uniform(dim.low, dim.high))
88
+ else:
89
+ val = float(rng.integers(int(dim.low), int(dim.high) + 1))
90
+ point.append(val)
91
+ candidates.append(point)
92
+ return candidates