sagari 1.0.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.
- sagari/__init__.py +38 -0
- sagari/_version.py +3 -0
- sagari/acquisition.py +264 -0
- sagari/benchmark.py +80 -0
- sagari/campaign.py +445 -0
- sagari/cli.py +146 -0
- sagari/errorbars.py +297 -0
- sagari/gp.py +139 -0
- sagari/monitor.py +151 -0
- sagari/posterior.py +375 -0
- sagari/replay.py +34 -0
- sagari/sobol.py +72 -0
- sagari/space.py +184 -0
- sagari-1.0.0.dist-info/METADATA +189 -0
- sagari-1.0.0.dist-info/RECORD +19 -0
- sagari-1.0.0.dist-info/WHEEL +5 -0
- sagari-1.0.0.dist-info/entry_points.txt +2 -0
- sagari-1.0.0.dist-info/licenses/LICENSE +21 -0
- sagari-1.0.0.dist-info/top_level.txt +1 -0
sagari/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""SAGARI: Schwarzschild Adaptive Gaussian-process Acquisition, Refinement, and Inference.
|
|
2
|
+
|
|
3
|
+
Adaptive Gaussian-process sampling for orbit-superposition modeling and other expensive, noisy chi2 surfaces.
|
|
4
|
+
|
|
5
|
+
The method of Waters & Gultekin (2026), "Schwarzschild Orbit-library Modeling with an Order of Magnitude
|
|
6
|
+
Fewer Models": a scrambled-Sobol initial design, refinement rounds by feasibility-weighted Expected
|
|
7
|
+
Improvement under a Gaussian-process surrogate with an explicit term for the model-to-model scatter of chi2,
|
|
8
|
+
a stopping rule on smoothed parameter ranges, error-bar rounds that test the surrogate out of sample, and a
|
|
9
|
+
posterior sampled on the final surrogate. It needs only the parameters and chi2 of each model, so it can
|
|
10
|
+
drive any modeling code.
|
|
11
|
+
|
|
12
|
+
Quick start::
|
|
13
|
+
|
|
14
|
+
import sagari as sg
|
|
15
|
+
space = sg.ParameterSpace.from_dict({"parameters": {
|
|
16
|
+
"mbh": {"bounds": [3e7, 5e9], "scale": "log"}, "upsilon": {"bounds": [4, 12]}}})
|
|
17
|
+
c = sg.Campaign.create("my_campaign", space, converge_on=["mbh", "upsilon"])
|
|
18
|
+
results = c.run(my_chi2_function) # or, on a cluster: c.next_round(results_csv)
|
|
19
|
+
gp, _, _ = c.surrogate(results)
|
|
20
|
+
post = sg.posterior.sample_posterior(gp, c.space)
|
|
21
|
+
"""
|
|
22
|
+
from . import errorbars, gp, monitor, posterior, replay
|
|
23
|
+
from ._version import __version__
|
|
24
|
+
from .acquisition import initial_design, propose_round
|
|
25
|
+
from .campaign import DEFAULTS, Campaign, read_results
|
|
26
|
+
from .errorbars import errorbar_round, gp_profiles, inference_gp, out_of_sample, profile_verdict
|
|
27
|
+
from .gp import PessimisticGP, feasibility_gp, fit_gp, training_window
|
|
28
|
+
from .monitor import estimate_converged, estimate_moved, smoothed_estimate, trusted_best_point
|
|
29
|
+
from .sobol import DEFAULT_SEED, SobolGlobalDesign
|
|
30
|
+
from .space import ParameterSpace
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"__version__", "ParameterSpace", "SobolGlobalDesign", "DEFAULT_SEED", "Campaign", "DEFAULTS", "read_results",
|
|
34
|
+
"initial_design", "propose_round", "smoothed_estimate", "estimate_moved", "estimate_converged",
|
|
35
|
+
"trusted_best_point", "inference_gp", "errorbar_round", "gp_profiles", "profile_verdict", "out_of_sample",
|
|
36
|
+
"PessimisticGP", "feasibility_gp", "fit_gp", "training_window",
|
|
37
|
+
"gp", "monitor", "errorbars", "posterior", "replay",
|
|
38
|
+
]
|
sagari/_version.py
ADDED
sagari/acquisition.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""One refinement round: feasibility-weighted Expected Improvement on a noisy chi2 surface (Section 3.5).
|
|
2
|
+
|
|
3
|
+
The round fits the main GP to the models below the hopelessness threshold and inside the training window,
|
|
4
|
+
scores a two-scale candidate pool by Expected Improvement against the surrogate's own best prediction at an
|
|
5
|
+
evaluated model, multiplies it by the probability (from the feasibility GP) that a candidate lies inside the
|
|
6
|
+
training window, takes the batch greedily subject to a minimum separation, verifies up to two isolated low
|
|
7
|
+
models by their neighbors, and fills the rest of the round from the campaign's global Sobol sequence.
|
|
8
|
+
|
|
9
|
+
The defaults are the settings of the paper (Matern 5/2 kernel, 30 sigma window, 35% global share). The
|
|
10
|
+
other options reproduce the variants of its benchmark (Appendix B) and should not normally be changed.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
from scipy.stats import norm
|
|
19
|
+
|
|
20
|
+
from .gp import feasibility_gp, fit_gp, fitted_scatter, training_window
|
|
21
|
+
from .sobol import DEFAULT_SEED, SobolGlobalDesign
|
|
22
|
+
from .space import as_space
|
|
23
|
+
|
|
24
|
+
STAGE_VERIFY, STAGE_LOCAL, STAGE_GLOBAL = "gp_ei_verify", "gp_ei_local", "sobol_global_continuation"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _greedy_diverse(order, points, n, min_sep):
|
|
28
|
+
"""Take points in ``order`` (best first), skipping any closer than ``min_sep`` to one already taken;
|
|
29
|
+
top up from the skipped ones if fewer than ``n`` survive."""
|
|
30
|
+
chosen = []
|
|
31
|
+
for i in order:
|
|
32
|
+
if len(chosen) == n:
|
|
33
|
+
break
|
|
34
|
+
if min_sep <= 0 or not chosen or np.min(np.linalg.norm(points[chosen] - points[i], axis=1)) >= min_sep:
|
|
35
|
+
chosen.append(i)
|
|
36
|
+
if len(chosen) < n:
|
|
37
|
+
taken = set(chosen)
|
|
38
|
+
chosen += [i for i in order if i not in taken][: n - len(chosen)]
|
|
39
|
+
return np.array(chosen, dtype=int)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _as_sobol(sobol, dim, seed) -> SobolGlobalDesign:
|
|
43
|
+
if isinstance(sobol, SobolGlobalDesign):
|
|
44
|
+
return sobol
|
|
45
|
+
if sobol is None:
|
|
46
|
+
raise ValueError("propose_round needs the campaign's Sobol state (a SobolGlobalDesign or its JSON path), so "
|
|
47
|
+
"that the global share continues the sequence of the initial design instead of repeating it")
|
|
48
|
+
return SobolGlobalDesign(dim, seed, state_path=Path(sobol))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def propose_round(
|
|
52
|
+
table: pd.DataFrame,
|
|
53
|
+
space,
|
|
54
|
+
n_new: int,
|
|
55
|
+
sobol,
|
|
56
|
+
seed: int = DEFAULT_SEED,
|
|
57
|
+
*,
|
|
58
|
+
kernel: str = "matern52",
|
|
59
|
+
train_window: float | None = 30.0,
|
|
60
|
+
global_fraction: float = 0.35,
|
|
61
|
+
n_train_cap: int = 300,
|
|
62
|
+
n_local_cap: int = 200,
|
|
63
|
+
n_candidates: int = 5000,
|
|
64
|
+
n_local_candidates: int = 4000,
|
|
65
|
+
local_scales: tuple = (0.01, 0.03, 0.08),
|
|
66
|
+
min_separation: float = 0.04,
|
|
67
|
+
verify: bool = True,
|
|
68
|
+
verify_step: float = 0.03,
|
|
69
|
+
verify_max: int = 2,
|
|
70
|
+
support_ok: float = 0.1,
|
|
71
|
+
noise_diagnostic: bool = True,
|
|
72
|
+
incumbent: str = "predicted",
|
|
73
|
+
redraw_candidates: bool = True,
|
|
74
|
+
feasibility: bool = True,
|
|
75
|
+
feasible_factor: float = 10.0,
|
|
76
|
+
max_train: int = 2000,
|
|
77
|
+
target: str = "raw",
|
|
78
|
+
log_offset: float = 20.0,
|
|
79
|
+
include_rest: bool = False,
|
|
80
|
+
exclude_bad_radius: float = 0.0,
|
|
81
|
+
exclude_bad_min: int = 2,
|
|
82
|
+
) -> pd.DataFrame:
|
|
83
|
+
"""Propose the next ``n_new`` models.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
table : DataFrame
|
|
88
|
+
Every model evaluated so far: one column per free parameter and ``chi2`` (NaN for a failed model).
|
|
89
|
+
Pass only the models inside the current box (:meth:`ParameterSpace.contains`).
|
|
90
|
+
space : ParameterSpace, dict or YAML path
|
|
91
|
+
n_new : int
|
|
92
|
+
Models in the round.
|
|
93
|
+
sobol : SobolGlobalDesign or path
|
|
94
|
+
The campaign's global sequence (advanced by the global share of this round).
|
|
95
|
+
seed : int
|
|
96
|
+
Seed of every random choice in the round (candidates, GP optimizer restarts).
|
|
97
|
+
kernel, train_window, global_fraction
|
|
98
|
+
Matern 5/2 or squared-exponential kernel; training window in units of sigma_chi2 (None: the best
|
|
99
|
+
``n_train_cap`` models); share of the round that continues the Sobol sequence.
|
|
100
|
+
n_train_cap, n_local_cap
|
|
101
|
+
Most models the main GP is fitted to; most points placed by EI in one round.
|
|
102
|
+
n_candidates, n_local_candidates, local_scales, redraw_candidates
|
|
103
|
+
The candidate pool: uniform points, plus Gaussian perturbations of widths ``local_scales`` around
|
|
104
|
+
the 20 best-predicted models; redrawn every round.
|
|
105
|
+
min_separation
|
|
106
|
+
Greedy batch selection skips candidates closer than this (unit cube) to one already taken.
|
|
107
|
+
verify, verify_step, verify_max, support_ok, noise_diagnostic
|
|
108
|
+
For up to ``verify_max`` models within 2 sigma_chi2 of the best with no other model within
|
|
109
|
+
``support_ok``, add the 2 x dim points +-``verify_step`` away along each axis. With
|
|
110
|
+
``noise_diagnostic=False`` the band is twice the sd of the 20 lowest chi2 instead of 2 sigma_chi2
|
|
111
|
+
(as in the paper's replays and benchmark).
|
|
112
|
+
incumbent
|
|
113
|
+
``"predicted"``: the lowest value the GP predicts at an evaluated model (Section 3.5);
|
|
114
|
+
``"raw"``: the lowest chi2 (correct only for noiseless evaluations).
|
|
115
|
+
feasibility, feasible_factor, max_train
|
|
116
|
+
Feasibility weighting of EI (:func:`sagari.gp.feasibility_gp`).
|
|
117
|
+
target, log_offset, include_rest, exclude_bad_radius, exclude_bad_min
|
|
118
|
+
Alternatives evaluated in the benchmark and not recommended: a log chi2 target, training on all
|
|
119
|
+
models with chi2 capped, and excluding candidates near clusters of bad models.
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
DataFrame
|
|
124
|
+
Free and fixed parameters and ``stage`` (``gp_ei_verify``, ``gp_ei_local``,
|
|
125
|
+
``sobol_global_continuation``). Diagnostics are in ``.attrs``: ``gp_noise_sd`` (sigma_chi2),
|
|
126
|
+
``raw_best``, ``n_verify``, ``n_train``, ``window_dchi2``, ``feasible_frac``, ``thr_dchi2``.
|
|
127
|
+
"""
|
|
128
|
+
space = as_space(space)
|
|
129
|
+
dim = space.dim
|
|
130
|
+
sobol = _as_sobol(sobol, dim, seed)
|
|
131
|
+
|
|
132
|
+
finite = table[np.isfinite(table["chi2"])]
|
|
133
|
+
failed = table[~np.isfinite(table["chi2"])]
|
|
134
|
+
if len(finite) < 2 * dim + 2:
|
|
135
|
+
raise ValueError(f"only {len(finite)} finite models -- too few to fit a {dim}-D Gaussian process; "
|
|
136
|
+
"run (more of) the initial design first")
|
|
137
|
+
|
|
138
|
+
# the main GP sees only models below the hopelessness threshold (at most n_train_cap), so the steep
|
|
139
|
+
# walls cannot set its length scales; the feasibility GP covers the rest
|
|
140
|
+
dchi2_all = finite["chi2"].values - finite["chi2"].min()
|
|
141
|
+
if feasibility:
|
|
142
|
+
gpf, _, thr_dchi2 = feasibility_gp(table, space, seed, feasible_factor, max_train)
|
|
143
|
+
usable = finite[dchi2_all <= thr_dchi2]
|
|
144
|
+
else:
|
|
145
|
+
usable = finite
|
|
146
|
+
if len(usable) < 2 * dim + 2:
|
|
147
|
+
usable = finite
|
|
148
|
+
best = usable.nsmallest(min(n_train_cap, len(usable)), "chi2")
|
|
149
|
+
X_best = space.to_unit(best)
|
|
150
|
+
chi2_best = best["chi2"].values
|
|
151
|
+
keep, win_scatter = training_window(X_best, chi2_best, dim, seed, train_window, kernel)
|
|
152
|
+
window_dchi2 = None
|
|
153
|
+
if win_scatter is not None:
|
|
154
|
+
window_dchi2 = float((chi2_best[keep] - chi2_best.min()).max())
|
|
155
|
+
best, X_best, chi2_best = best[keep], X_best[keep], chi2_best[keep]
|
|
156
|
+
chi2_min = chi2_best.min()
|
|
157
|
+
X_train, chi2_train = X_best, chi2_best
|
|
158
|
+
n_capped = 0
|
|
159
|
+
if include_rest:
|
|
160
|
+
y_cap = chi2_best.max()
|
|
161
|
+
others = pd.concat([finite.drop(best.index), failed])
|
|
162
|
+
if len(others) > max_train - len(best):
|
|
163
|
+
others = others.sample(max_train - len(best), random_state=seed)
|
|
164
|
+
if len(others):
|
|
165
|
+
X_train = np.vstack([X_best, space.to_unit(others)])
|
|
166
|
+
chi2_train = np.concatenate([chi2_best, np.full(len(others), y_cap)])
|
|
167
|
+
n_capped = len(others)
|
|
168
|
+
if target == "log":
|
|
169
|
+
y_train = np.log(chi2_train - chi2_min + log_offset)
|
|
170
|
+
elif target == "raw":
|
|
171
|
+
y_train = chi2_train
|
|
172
|
+
else:
|
|
173
|
+
raise ValueError(f"target must be 'raw' or 'log', got {target!r}")
|
|
174
|
+
gp = fit_gp(X_train, y_train, dim, seed, kernel)
|
|
175
|
+
|
|
176
|
+
noise_sd = None
|
|
177
|
+
if noise_diagnostic:
|
|
178
|
+
gp_raw = gp if (target == "raw" and n_capped == 0) else fit_gp(X_best, chi2_best, dim, seed, kernel)
|
|
179
|
+
noise_sd = fitted_scatter(gp_raw, chi2_best)
|
|
180
|
+
|
|
181
|
+
n_global = int(round(n_new * global_fraction))
|
|
182
|
+
n_local = min(n_new - n_global, n_local_cap)
|
|
183
|
+
n_global = n_new - n_local
|
|
184
|
+
|
|
185
|
+
mu_train = gp.predict(X_train)
|
|
186
|
+
f_best = mu_train.min() if incumbent == "predicted" else y_train.min()
|
|
187
|
+
|
|
188
|
+
# verification points around isolated low models
|
|
189
|
+
verify_unit = np.empty((0, dim))
|
|
190
|
+
if verify and n_local > 0:
|
|
191
|
+
X_all = space.to_unit(finite)
|
|
192
|
+
d_nn = np.array([np.sort(np.linalg.norm(X_all - x, axis=1))[1] for x in X_best])
|
|
193
|
+
band = 2.0 * (noise_sd if noise_sd else np.std(np.sort(chi2_best)[:20]))
|
|
194
|
+
lone = np.where((d_nn > support_ok) & (chi2_best <= chi2_min + band))[0]
|
|
195
|
+
lone = lone[np.argsort(chi2_best[lone])][:verify_max]
|
|
196
|
+
offs = np.vstack([s * verify_step * np.eye(dim)[k] for k in range(dim) for s in (+1, -1)])
|
|
197
|
+
if len(lone):
|
|
198
|
+
verify_unit = np.clip(np.vstack([X_best[i] + offs for i in lone]), 0.0, 1.0)[:n_local]
|
|
199
|
+
|
|
200
|
+
# candidate pool: uniform, plus perturbations of the best-predicted models
|
|
201
|
+
cand_rng = np.random.default_rng([seed, 999, len(finite)] if redraw_candidates else seed + 999)
|
|
202
|
+
candidates = [cand_rng.random((n_candidates, dim))]
|
|
203
|
+
if n_local_candidates > 0:
|
|
204
|
+
n_cent = min(20, len(X_train))
|
|
205
|
+
centres = X_train[np.argsort(mu_train)[:n_cent]]
|
|
206
|
+
per = max(1, n_local_candidates // (n_cent * len(local_scales)))
|
|
207
|
+
for sc in local_scales:
|
|
208
|
+
for c in centres:
|
|
209
|
+
candidates.append(c + sc * cand_rng.standard_normal((per, dim)))
|
|
210
|
+
candidates = np.clip(np.vstack(candidates), 0.0, 1.0)
|
|
211
|
+
|
|
212
|
+
n_excluded = 0
|
|
213
|
+
if exclude_bad_radius > 0:
|
|
214
|
+
from scipy.spatial import cKDTree
|
|
215
|
+
X_all = space.to_unit(table)
|
|
216
|
+
good = table.index.isin(best.index)
|
|
217
|
+
neigh = cKDTree(X_all).query_ball_point(candidates, exclude_bad_radius)
|
|
218
|
+
ok = np.array([len(nb) < exclude_bad_min or good[nb].any() for nb in neigh])
|
|
219
|
+
n_excluded = int((~ok).sum())
|
|
220
|
+
if ok.sum() >= n_local:
|
|
221
|
+
candidates = candidates[ok]
|
|
222
|
+
|
|
223
|
+
mu, sigma = gp.predict(candidates, return_std=True)
|
|
224
|
+
sigma = np.maximum(sigma, 1e-12)
|
|
225
|
+
z = (f_best - mu) / sigma
|
|
226
|
+
ei = (f_best - mu) * norm.cdf(z) + sigma * norm.pdf(z)
|
|
227
|
+
|
|
228
|
+
p_ok_mean = None
|
|
229
|
+
if feasibility:
|
|
230
|
+
# with a training window the main GP knows only the region inside it, so "feasible" means inside
|
|
231
|
+
# the window as the feasibility GP (fitted to every model) predicts it
|
|
232
|
+
thr = np.log10((window_dchi2 if window_dchi2 is not None else thr_dchi2) + 20.0)
|
|
233
|
+
mf, sf = gpf.predict(candidates, return_std=True)
|
|
234
|
+
p_ok = norm.cdf((thr - mf) / np.maximum(sf, 1e-12))
|
|
235
|
+
ei = ei * p_ok
|
|
236
|
+
p_ok_mean = float(p_ok.mean())
|
|
237
|
+
n_ei = n_local - len(verify_unit)
|
|
238
|
+
pick = _greedy_diverse(np.argsort(-ei), candidates, n_ei, min_separation)
|
|
239
|
+
local_unit = np.vstack([verify_unit, candidates[pick]])
|
|
240
|
+
|
|
241
|
+
global_unit = sobol.extend(n_global)
|
|
242
|
+
out = space.from_unit(np.vstack([local_unit, global_unit]), with_fixed=True)
|
|
243
|
+
out["stage"] = ([STAGE_VERIFY] * len(verify_unit) + [STAGE_LOCAL] * (n_local - len(verify_unit))
|
|
244
|
+
+ [STAGE_GLOBAL] * n_global)
|
|
245
|
+
out.attrs.update(gp_noise_sd=noise_sd, raw_best=float(chi2_min), n_verify=int(len(verify_unit)),
|
|
246
|
+
gp_target=target, n_train=int(len(chi2_train)), train_window=train_window, kernel=kernel,
|
|
247
|
+
window_dchi2=window_dchi2, n_capped=int(n_capped), n_failed_in_table=int(len(failed)),
|
|
248
|
+
n_candidates_excluded=n_excluded, feasible_frac=p_ok_mean,
|
|
249
|
+
thr_dchi2=float(thr_dchi2) if feasibility else None)
|
|
250
|
+
return out
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def initial_design(space, n: int, sobol) -> pd.DataFrame:
|
|
254
|
+
"""The first ``n`` points of the campaign's scrambled Sobol sequence (round 0), with ``stage``
|
|
255
|
+
``sobol_global``. ``sobol`` is the campaign's :class:`SobolGlobalDesign` (or its JSON path, read with
|
|
256
|
+
the default seed)."""
|
|
257
|
+
space = as_space(space)
|
|
258
|
+
sobol = _as_sobol(sobol, space.dim, DEFAULT_SEED)
|
|
259
|
+
out = space.from_unit(sobol.extend(n), with_fixed=True)
|
|
260
|
+
out["stage"] = "sobol_global"
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
__all__ = ["propose_round", "initial_design"]
|
sagari/benchmark.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The synthetic two-basin objective of Appendix B of the paper.
|
|
2
|
+
|
|
3
|
+
An analytic chi2 in the four parameters of the M 86 pilot campaign (Upsilon and v_c on linear axes, M_BH and
|
|
4
|
+
r_c on logarithmic ones): a broad, shallow basin and a narrow, tilted one deeper by 50, with Gaussian scatter
|
|
5
|
+
of sd 47 per model, the scatter fitted to the pilot. Optionally a region of failed and very-high-chi2 models
|
|
6
|
+
(``hopeless=True``): the 15% of the box with u_Upsilon > 0.7 and u_v > 0.5, where 15% of models fail.
|
|
7
|
+
|
|
8
|
+
>>> obj = TwoBasin(hopeless=True)
|
|
9
|
+
>>> chi2 = obj(design_dataframe, rng) # NaN for failed models
|
|
10
|
+
>>> obj.found(estimate) # is a smoothed best point in the deeper basin?
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from .space import ParameterSpace
|
|
18
|
+
|
|
19
|
+
PILOT_SPACE = ParameterSpace(
|
|
20
|
+
free_params=["upsilon", "mbh", "v_c", "r_c"],
|
|
21
|
+
bounds={"upsilon": (1.0, 15.0), "mbh": (3.0e7, 5.0e9), "v_c": (0.0, 500.0), "r_c": (0.05, 20.0)},
|
|
22
|
+
fixed_values={"xi": 1.0, "inclination": 89.0},
|
|
23
|
+
log_params=frozenset({"mbh", "r_c"}),
|
|
24
|
+
name="two_basin",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
NOISE = 47.0 # chi2 scatter per model
|
|
28
|
+
SCALE = 35.0 # chi2 rise at one basin width
|
|
29
|
+
CENTRE_BROAD = dict(mbh=1.5e8, r_c=2.0, upsilon=9.3, v_c=60.0)
|
|
30
|
+
CENTRE_NARROW = dict(mbh=8.0e8, r_c=0.3, upsilon=3.7, v_c=350.0)
|
|
31
|
+
WIDTH_BROAD = {"upsilon": 0.06, "mbh": 0.25, "v_c": 0.40, "r_c": 1.00}
|
|
32
|
+
WIDTH_NARROW = {"upsilon": 0.035, "mbh": 0.15, "v_c": 0.05, "r_c": 0.08}
|
|
33
|
+
FAIL_ABOVE, FAIL_FRAC = 5e4, 0.15 # models with true Delta chi2 > 5e4 fail 15% of the time
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TwoBasin:
|
|
37
|
+
"""Callable objective: ``obj(design, rng) -> chi2`` (NaN for failures)."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, hopeless: bool = False, noise: float = NOISE, space: ParameterSpace = PILOT_SPACE):
|
|
40
|
+
self.space, self.hopeless, self.noise = space, hopeless, noise
|
|
41
|
+
self.P = {p: i for i, p in enumerate(space.free_params)}
|
|
42
|
+
self.c_b = space.to_unit(pd.DataFrame([CENTRE_BROAD]))[0]
|
|
43
|
+
self.c_n = space.to_unit(pd.DataFrame([CENTRE_NARROW]))[0]
|
|
44
|
+
|
|
45
|
+
def true(self, U):
|
|
46
|
+
"""Noise-free chi2 at unit-cube points, and whether the narrow basin is the lower one there."""
|
|
47
|
+
U = np.atleast_2d(U)
|
|
48
|
+
P, cb, cn = self.P, self.c_b, self.c_n
|
|
49
|
+
qB = sum(((U[:, P[p]] - cb[P[p]]) / WIDTH_BROAD[p]) ** 2 for p in P)
|
|
50
|
+
du = (U[:, P["upsilon"]] - cn[P["upsilon"]]) / WIDTH_NARROW["upsilon"]
|
|
51
|
+
dv = (U[:, P["v_c"]] - cn[P["v_c"]]) / WIDTH_NARROW["v_c"]
|
|
52
|
+
# tilted valley: raising v_c compensates lowering Upsilon (along du ~ -dv the rise is 5x slower)
|
|
53
|
+
qN = ((du + dv) / np.sqrt(2)) ** 2 + (((du - dv) / np.sqrt(2)) / 5.0) ** 2 \
|
|
54
|
+
+ ((U[:, P["mbh"]] - cn[P["mbh"]]) / WIDTH_NARROW["mbh"]) ** 2 \
|
|
55
|
+
+ ((U[:, P["r_c"]] - cn[P["r_c"]]) / WIDTH_NARROW["r_c"]) ** 2
|
|
56
|
+
fB, fN = 1050 + SCALE * qB, 1000 + SCALE * qN
|
|
57
|
+
f = np.minimum(fB, fN)
|
|
58
|
+
if self.hopeless:
|
|
59
|
+
f = f + 6e4 * self.in_hopeless(U) * (1 + 2 * (U[:, P["v_c"]] - 0.5) / 0.5)
|
|
60
|
+
return f, fN < fB
|
|
61
|
+
|
|
62
|
+
def in_hopeless(self, U):
|
|
63
|
+
U = np.atleast_2d(U)
|
|
64
|
+
return ((U[:, self.P["upsilon"]] > 0.7) & (U[:, self.P["v_c"]] > 0.5)).astype(float)
|
|
65
|
+
|
|
66
|
+
def __call__(self, design: pd.DataFrame, rng) -> np.ndarray:
|
|
67
|
+
f, _ = self.true(self.space.to_unit(design))
|
|
68
|
+
chi2 = f + rng.normal(0, self.noise, len(f))
|
|
69
|
+
fail = (f - 1000 > FAIL_ABOVE) & (rng.random(len(f)) < FAIL_FRAC)
|
|
70
|
+
chi2[fail] = np.nan
|
|
71
|
+
return chi2
|
|
72
|
+
|
|
73
|
+
def found(self, estimate: dict) -> bool:
|
|
74
|
+
"""True if the best point of a monitoring estimate lies where the narrow basin is the lower one."""
|
|
75
|
+
u = self.space.to_unit(np.array([[estimate[p][0] for p in self.space.free_params]]))
|
|
76
|
+
return bool(self.true(u)[1][0])
|
|
77
|
+
|
|
78
|
+
def mbh_error(self, estimate: dict) -> float:
|
|
79
|
+
"""abs(log10(M_BH / 8e8)) of a monitoring estimate's best point (dex)."""
|
|
80
|
+
return float(abs(np.log10(estimate["mbh"][0] / CENTRE_NARROW["mbh"])))
|