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 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
@@ -0,0 +1,3 @@
1
+ """Single source of the package version, read by pyproject.toml and by the release workflow without
2
+ importing the package."""
3
+ __version__ = "1.0.0"
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"])))