quantecarlo 0.1.2__tar.gz → 0.2.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.
- quantecarlo-0.2.0/PKG-INFO +163 -0
- quantecarlo-0.2.0/README.md +148 -0
- {quantecarlo-0.1.2 → quantecarlo-0.2.0}/pyproject.toml +1 -1
- quantecarlo-0.2.0/quantecarlo/__init__.py +11 -0
- {quantecarlo-0.1.2 → quantecarlo-0.2.0}/quantecarlo/_fantasize.py +12 -6
- quantecarlo-0.2.0/quantecarlo/_modal_api.py +152 -0
- quantecarlo-0.2.0/quantecarlo/bo_sampler.py +92 -0
- quantecarlo-0.2.0/quantecarlo.egg-info/PKG-INFO +163 -0
- {quantecarlo-0.1.2 → quantecarlo-0.2.0}/quantecarlo.egg-info/SOURCES.txt +5 -2
- quantecarlo-0.2.0/tests/test_bo_sampler.py +202 -0
- quantecarlo-0.2.0/tests/test_fantasize.py +96 -0
- quantecarlo-0.2.0/tests/test_modal_live.py +89 -0
- quantecarlo-0.1.2/PKG-INFO +0 -205
- quantecarlo-0.1.2/README.md +0 -190
- quantecarlo-0.1.2/quantecarlo/__init__.py +0 -5
- quantecarlo-0.1.2/quantecarlo/bo_sampler.py +0 -195
- quantecarlo-0.1.2/quantecarlo/bo_sampler_multigroup.py +0 -413
- quantecarlo-0.1.2/quantecarlo.egg-info/PKG-INFO +0 -205
- {quantecarlo-0.1.2 → quantecarlo-0.2.0}/quantecarlo.egg-info/dependency_links.txt +0 -0
- {quantecarlo-0.1.2 → quantecarlo-0.2.0}/quantecarlo.egg-info/requires.txt +0 -0
- {quantecarlo-0.1.2 → quantecarlo-0.2.0}/quantecarlo.egg-info/top_level.txt +0 -0
- {quantecarlo-0.1.2 → quantecarlo-0.2.0}/setup.cfg +0 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quantecarlo
|
|
3
|
+
Version: 0.2.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.
|
|
7
|
+
version = "0.2.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
|
|
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
|
-
#
|
|
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 (
|
|
115
|
-
best_y = y_aug.
|
|
116
|
-
z = (
|
|
117
|
-
ei = (
|
|
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
|