sagari 1.0.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.
Files changed (60) hide show
  1. sagari-1.0.0/CHANGELOG.md +27 -0
  2. sagari-1.0.0/CITATION.cff +40 -0
  3. sagari-1.0.0/LICENSE +21 -0
  4. sagari-1.0.0/MANIFEST.in +6 -0
  5. sagari-1.0.0/PKG-INFO +189 -0
  6. sagari-1.0.0/README.md +123 -0
  7. sagari-1.0.0/benchmarks/two_basin.py +129 -0
  8. sagari-1.0.0/docs/analysis.md +77 -0
  9. sagari-1.0.0/docs/api.rst +32 -0
  10. sagari-1.0.0/docs/changelog.md +2 -0
  11. sagari-1.0.0/docs/citing.md +8 -0
  12. sagari-1.0.0/docs/cluster.md +109 -0
  13. sagari-1.0.0/docs/concepts.md +74 -0
  14. sagari-1.0.0/docs/conf.py +29 -0
  15. sagari-1.0.0/docs/configuration.md +72 -0
  16. sagari-1.0.0/docs/faq.md +43 -0
  17. sagari-1.0.0/docs/index.md +53 -0
  18. sagari-1.0.0/docs/installation.md +31 -0
  19. sagari-1.0.0/docs/quickstart.md +51 -0
  20. sagari-1.0.0/docs/reproducibility.md +42 -0
  21. sagari-1.0.0/examples/README.md +20 -0
  22. sagari-1.0.0/examples/quickstart.py +47 -0
  23. sagari-1.0.0/examples/sco_runalls.py +106 -0
  24. sagari-1.0.0/pyproject.toml +88 -0
  25. sagari-1.0.0/setup.cfg +4 -0
  26. sagari-1.0.0/src/sagari/__init__.py +38 -0
  27. sagari-1.0.0/src/sagari/_version.py +3 -0
  28. sagari-1.0.0/src/sagari/acquisition.py +264 -0
  29. sagari-1.0.0/src/sagari/benchmark.py +80 -0
  30. sagari-1.0.0/src/sagari/campaign.py +445 -0
  31. sagari-1.0.0/src/sagari/cli.py +146 -0
  32. sagari-1.0.0/src/sagari/errorbars.py +297 -0
  33. sagari-1.0.0/src/sagari/gp.py +139 -0
  34. sagari-1.0.0/src/sagari/monitor.py +151 -0
  35. sagari-1.0.0/src/sagari/posterior.py +375 -0
  36. sagari-1.0.0/src/sagari/replay.py +34 -0
  37. sagari-1.0.0/src/sagari/sobol.py +72 -0
  38. sagari-1.0.0/src/sagari/space.py +184 -0
  39. sagari-1.0.0/src/sagari.egg-info/PKG-INFO +189 -0
  40. sagari-1.0.0/src/sagari.egg-info/SOURCES.txt +58 -0
  41. sagari-1.0.0/src/sagari.egg-info/dependency_links.txt +1 -0
  42. sagari-1.0.0/src/sagari.egg-info/entry_points.txt +2 -0
  43. sagari-1.0.0/src/sagari.egg-info/requires.txt +24 -0
  44. sagari-1.0.0/src/sagari.egg-info/top_level.txt +1 -0
  45. sagari-1.0.0/tests/conftest.py +5 -0
  46. sagari-1.0.0/tests/fixtures/golden.json +1 -0
  47. sagari-1.0.0/tests/fixtures/make_golden.py +48 -0
  48. sagari-1.0.0/tests/helpers.py +24 -0
  49. sagari-1.0.0/tests/test_acquisition.py +59 -0
  50. sagari-1.0.0/tests/test_benchmark.py +25 -0
  51. sagari-1.0.0/tests/test_campaign.py +129 -0
  52. sagari-1.0.0/tests/test_errorbars.py +32 -0
  53. sagari-1.0.0/tests/test_examples.py +36 -0
  54. sagari-1.0.0/tests/test_golden.py +76 -0
  55. sagari-1.0.0/tests/test_monitor.py +29 -0
  56. sagari-1.0.0/tests/test_parity.py +192 -0
  57. sagari-1.0.0/tests/test_posterior.py +49 -0
  58. sagari-1.0.0/tests/test_replay.py +32 -0
  59. sagari-1.0.0/tests/test_sobol.py +26 -0
  60. sagari-1.0.0/tests/test_space.py +55 -0
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 (2026-10-15)
4
+
5
+ First public release of SAGARI (Schwarzschild Adaptive Gaussian-process Acquisition, Refinement, and
6
+ Inference), accompanying Waters & Gültekin (2026).
7
+
8
+ - `ParameterSpace`: bounds, linear and logarithmic axes, fixed parameters; YAML and dict input.
9
+ - `SobolGlobalDesign` / `initial_design`: persistent scrambled-Sobol global design.
10
+ - `propose_round`: refinement rounds by feasibility-weighted Expected Improvement with a nugget for the
11
+ model-to-model scatter, a training window in units of that scatter, a denoised incumbent, a two-scale
12
+ candidate pool, a minimum separation, verification of isolated low models, and a global Sobol share.
13
+ - `smoothed_estimate`, `estimate_converged`: monitoring estimate and stopping rule; `trusted_best_point`.
14
+ - `errorbar_round`, `inference_gp`, `gp_profiles`, `profile_verdict`, `out_of_sample`: error-bar rounds,
15
+ the inference surrogate (optionally with an automatically chosen training window) and its checks.
16
+ - `posterior`: the ensemble-MCMC posterior on the supported surrogate, surrogate-marginalized quantiles and
17
+ mode shares, Δχ² profiles and their parametric-bootstrap widening.
18
+ - `Campaign` and the `sagari` command line: file-based campaigns for clusters, and an in-process loop.
19
+ - `replay`, `benchmark`: archive replays and the two-basin objective of the paper; `benchmarks/two_basin.py`.
20
+ - Parity tests against the original campaign code of the paper, and a golden fixture written by it.
21
+ - Four executed example notebooks (a first campaign; the cluster workflow; the final analysis on a two-solution
22
+ problem; the two-basin benchmark and an archive replay), a documentation site (concepts, cluster workflow,
23
+ configuration reference, analysis, reproducibility, FAQ), and example space and settings files.
24
+ - `posterior.surface_draws` raises the covariance jitter when the factorization fails (posterior samples much
25
+ closer together than the kernel's length scales); the first attempt uses the paper's value.
26
+ - Tested on Python 3.10–3.13, down to NumPy 1.26, SciPy 1.11, scikit-learn 1.3 and pandas 2.0, and up to
27
+ pandas 3.0.
@@ -0,0 +1,40 @@
1
+ cff-version: 1.2.0
2
+ title: "SAGARI: Schwarzschild Adaptive Gaussian-process Acquisition, Refinement, and Inference"
3
+ message: "If you use this software, please cite the paper below and the software."
4
+ type: software
5
+ authors:
6
+ - family-names: Waters
7
+ given-names: Thomas K.
8
+ affiliation: University of Michigan
9
+ orcid: "https://orcid.org/0000-0002-5231-7240"
10
+ - family-names: Gültekin
11
+ given-names: Kayhan
12
+ affiliation: University of Michigan
13
+ orcid: "https://orcid.org/0000-0002-1146-0198"
14
+ version: 1.0.0
15
+ date-released: "2026-10-15"
16
+ url: "https://github.com/thomas-k-waters/sagari"
17
+ license: MIT
18
+ keywords:
19
+ - astronomy
20
+ - supermassive black holes
21
+ - Schwarzschild modeling
22
+ - Bayesian optimization
23
+ - Gaussian processes
24
+ abstract: >
25
+ SAGARI replaces the nested parameter grids of orbit-superposition modeling campaigns with adaptive
26
+ rounds: a scrambled Sobol initial design, refinement by feasibility-weighted Expected Improvement under a
27
+ Gaussian-process surrogate with an explicit term for the model-to-model scatter of chi2, a stopping rule on
28
+ smoothed parameter ranges, error-bar rounds that test the surrogate out of sample, and a posterior sampled
29
+ on the final surrogate. It needs only the parameters and chi2 of each model.
30
+ preferred-citation:
31
+ type: article
32
+ authors:
33
+ - family-names: Waters
34
+ given-names: Thomas K.
35
+ - family-names: Gültekin
36
+ given-names: Kayhan
37
+ title: "Schwarzschild Orbit-library Modeling with an Order of Magnitude Fewer Models: Adaptive Gaussian-process Sampling and a Case Study of M 86"
38
+ journal: "The Astrophysical Journal"
39
+ year: 2026
40
+ notes: "submitted"
sagari-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas K. Waters
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ include README.md CHANGELOG.md CITATION.cff LICENSE
2
+ recursive-include tests *.py *.json
3
+ recursive-include examples *.py *.md
4
+ recursive-include benchmarks *.py
5
+ recursive-include docs *.py *.md *.rst
6
+ global-exclude __pycache__ *.py[cod] .DS_Store
sagari-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.4
2
+ Name: sagari
3
+ Version: 1.0.0
4
+ Summary: SAGARI: Schwarzschild Adaptive Gaussian-process Acquisition, Refinement, and Inference -- adaptive sampling for orbit-superposition modeling and other expensive, noisy chi2 surfaces
5
+ Author-email: "Thomas K. Waters" <waterstk@umich.edu>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Thomas K. Waters
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/thomas-k-waters/sagari
29
+ Project-URL: Bug Tracker, https://github.com/thomas-k-waters/sagari/issues
30
+ Project-URL: Documentation, https://sagari.readthedocs.io
31
+ Keywords: astronomy,black holes,Schwarzschild modeling,orbit superposition,Bayesian optimization,Gaussian process,expected improvement,Sobol sequence
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: Science/Research
34
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.10
38
+ Classifier: Programming Language :: Python :: 3.11
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: Programming Language :: Python :: 3.13
41
+ Requires-Python: >=3.10
42
+ Description-Content-Type: text/markdown
43
+ License-File: LICENSE
44
+ Requires-Dist: numpy>=1.26
45
+ Requires-Dist: scipy>=1.11
46
+ Requires-Dist: pandas>=2.0
47
+ Requires-Dist: scikit-learn>=1.3
48
+ Requires-Dist: emcee>=3.1
49
+ Requires-Dist: pyyaml>=6.0
50
+ Provides-Extra: plot
51
+ Requires-Dist: matplotlib>=3.7; extra == "plot"
52
+ Requires-Dist: corner>=2.2; extra == "plot"
53
+ Provides-Extra: docs
54
+ Requires-Dist: sphinx>=7.0; extra == "docs"
55
+ Requires-Dist: sphinx-rtd-theme>=2.0; extra == "docs"
56
+ Requires-Dist: myst-nb>=1.0; extra == "docs"
57
+ Requires-Dist: sphinx-copybutton>=0.5; extra == "docs"
58
+ Requires-Dist: matplotlib>=3.7; extra == "docs"
59
+ Provides-Extra: dev
60
+ Requires-Dist: pytest>=7.0; extra == "dev"
61
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
62
+ Requires-Dist: ruff>=0.4; extra == "dev"
63
+ Requires-Dist: build>=1.0; extra == "dev"
64
+ Requires-Dist: twine>=5.0; extra == "dev"
65
+ Dynamic: license-file
66
+
67
+ # SAGARI
68
+
69
+ **Schwarzschild Adaptive Gaussian-process Acquisition, Refinement, and Inference**
70
+
71
+ [![Tests](https://github.com/thomas-k-waters/sagari/actions/workflows/tests.yml/badge.svg)](https://github.com/thomas-k-waters/sagari/actions/workflows/tests.yml)
72
+ [![PyPI](https://img.shields.io/pypi/v/sagari.svg)](https://pypi.org/project/sagari/)
73
+ [![Documentation](https://readthedocs.org/projects/sagari/badge/?version=latest)](https://sagari.readthedocs.io)
74
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
75
+
76
+ Adaptive Gaussian-process sampling for Schwarzschild orbit-superposition modeling, and for any other model
77
+ whose goodness of fit is an expensive, noisy χ².
78
+
79
+ An orbit-superposition model costs hours of CPU time, and a nested parameter grid needs ~10⁴ of them.
80
+ SAGARI replaces the grid with a sequence of rounds, each placed where a surrogate of the χ² surface predicts
81
+ the largest gain, and stops the campaign when the answer stops moving. On the archived models of two published
82
+ black-hole mass measurements it recovered the published mass in every replayed trial with **9 to 30 times fewer
83
+ models** than the original campaigns.
84
+
85
+ It is the method of
86
+
87
+ > Waters & Gültekin (2026), *Schwarzschild Orbit-library Modeling with an Order of Magnitude Fewer Models:
88
+ > Adaptive Gaussian-process Sampling and a Case Study of M 86*
89
+
90
+ and reproduces the code used there exactly ([details](#fidelity-to-the-paper)).
91
+
92
+ The package never runs a model. It proposes parameter sets and reads back their χ², so it can drive any code: an
93
+ orbit-superposition pipeline on a cluster, a Jeans model, or a lookup in an archive.
94
+
95
+ 📖 **Documentation:** https://sagari.readthedocs.io
96
+
97
+ ## Installation
98
+
99
+ ```bash
100
+ pip install sagari # add [plot] for the example notebooks
101
+ ```
102
+
103
+ Python ≥ 3.10. Depends on NumPy, SciPy, pandas, scikit-learn, emcee and PyYAML.
104
+
105
+ ## Quick start
106
+
107
+ ```python
108
+ import sagari as sg
109
+
110
+ space = sg.ParameterSpace.from_dict({"parameters": {
111
+ "mbh": {"bounds": [1e6, 1e10], "scale": "log"},
112
+ "upsilon": {"bounds": [1.0, 10.0]},
113
+ "v_c": {"bounds": [0.0, 500.0]},
114
+ "inclination": {"value": 60.0}, # fixed
115
+ }})
116
+
117
+ def my_model(design): # a DataFrame of parameter sets -> one chi2 per row
118
+ ... # (NaN for a model that failed)
119
+
120
+ c = sg.Campaign.create("my_campaign", space, converge_on=["mbh", "upsilon"])
121
+ results = c.run(my_model) # Sobol design, search rounds, stopping rule, error-bar rounds
122
+
123
+ gp, _, _ = c.surrogate(results) # the final surrogate of the chi2 surface
124
+ post = sg.posterior.sample_posterior(gp, space)
125
+ ```
126
+
127
+ ## On a cluster
128
+
129
+ When each model is a batch job, the campaign lives in a directory and moves one round at a time:
130
+
131
+ ```bash
132
+ sagari init my_campaign --space space.yaml --converge-on mbh,upsilon
133
+ sagari next my_campaign # writes my_campaign/rounds/round_000.csv
134
+ # ... run the models of the round with your own code; add model_id,chi2 rows to results.csv ...
135
+ sagari next my_campaign --results results.csv # the next round (search or error bars), or "done"
136
+ sagari status my_campaign --results results.csv # ranges per round, stopping rule, out-of-sample checks
137
+ sagari posterior my_campaign --results results.csv --out posterior --profiles
138
+ ```
139
+
140
+ Failed models are written as `chi2 = nan` and kept: the search learns to avoid where models fail.
141
+ `examples/sco_runalls.py` connects a campaign to the Fortran pipeline of the paper (`runallt` commands file in,
142
+ `cres` files out).
143
+
144
+ ## How it works
145
+
146
+ 1. **Initial design**: a scrambled Sobol sequence over the box, continued by every later round.
147
+ 2. **Search rounds**: a Gaussian process with a Matérn 5/2 kernel and an explicit nugget for the model-to-model
148
+ scatter of χ², fitted near the minimum. A second GP fitted to every model keeps the search out of regions that
149
+ are hopeless or where models fail. Each round places 65% of its models by feasibility-weighted Expected
150
+ Improvement, with a denoised incumbent, two-scale candidates and a minimum separation, and verifies isolated
151
+ low models by their neighbours. The other 35% continues the Sobol sequence.
152
+ 3. **Stopping rule**: smoothed 16–84% ranges of the parameters you care about. The search stops when they move
153
+ by less than 0.2 of their width, after at least two refinement rounds.
154
+ 4. **Error-bar rounds**: models fill the acceptable region and the parameter profiles. Each model's χ² is
155
+ predicted before it runs, so the surrogate is tested out of sample.
156
+ 5. **Posterior**: MCMC on the final surrogate where models constrain it, averaged over the surrogate's own
157
+ uncertainty, plus Δχ² profile intervals.
158
+
159
+ The defaults are the settings adopted and validated in the paper.
160
+
161
+ ## Example notebooks
162
+
163
+ | notebook | |
164
+ | --- | --- |
165
+ | [01 Your first campaign](examples/notebooks/01_quickstart.ipynb) | a complete campaign in Python on a noisy analytic χ² |
166
+ | [02 A campaign on a cluster](examples/notebooks/02_cluster_workflow.ipynb) | round files, results tables, failures, narrowing the box, the CLI |
167
+ | [03 From the surrogate to a measurement](examples/notebooks/03_analysis.ipynb) | profiles, posteriors, two solutions, surrogate uncertainty |
168
+ | [04 Benchmark and replay](examples/notebooks/04_benchmark_and_replay.ipynb) | the paper's two-basin benchmark and a replay on an archive |
169
+
170
+ ## Fidelity to the paper
171
+
172
+ The package is a port of the campaign code used in the paper, not a reimplementation:
173
+
174
+ - `tests/test_parity.py` runs the original code and the package side by side and requires identical output at
175
+ every stage (needs the original code; see the docs).
176
+ - `tests/test_golden.py` checks the same outputs against a fixture written by the original code, and runs anywhere.
177
+ - Replays of the paper's NGC 5102 and NGC 3258 campaigns, and its two-basin benchmark
178
+ (`benchmarks/two_basin.py`), are reproduced model for model.
179
+
180
+ Exact repetition from a seed also needs the same library versions and the same number of linear-algebra threads
181
+ (`OMP_NUM_THREADS=1`).
182
+
183
+ ## Citing
184
+
185
+ Please cite Waters & Gültekin (2026) and the software (`CITATION.cff`).
186
+
187
+ ## License
188
+
189
+ MIT. See [LICENSE](LICENSE).
sagari-1.0.0/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # SAGARI
2
+
3
+ **Schwarzschild Adaptive Gaussian-process Acquisition, Refinement, and Inference**
4
+
5
+ [![Tests](https://github.com/thomas-k-waters/sagari/actions/workflows/tests.yml/badge.svg)](https://github.com/thomas-k-waters/sagari/actions/workflows/tests.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/sagari.svg)](https://pypi.org/project/sagari/)
7
+ [![Documentation](https://readthedocs.org/projects/sagari/badge/?version=latest)](https://sagari.readthedocs.io)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
9
+
10
+ Adaptive Gaussian-process sampling for Schwarzschild orbit-superposition modeling, and for any other model
11
+ whose goodness of fit is an expensive, noisy χ².
12
+
13
+ An orbit-superposition model costs hours of CPU time, and a nested parameter grid needs ~10⁴ of them.
14
+ SAGARI replaces the grid with a sequence of rounds, each placed where a surrogate of the χ² surface predicts
15
+ the largest gain, and stops the campaign when the answer stops moving. On the archived models of two published
16
+ black-hole mass measurements it recovered the published mass in every replayed trial with **9 to 30 times fewer
17
+ models** than the original campaigns.
18
+
19
+ It is the method of
20
+
21
+ > Waters & Gültekin (2026), *Schwarzschild Orbit-library Modeling with an Order of Magnitude Fewer Models:
22
+ > Adaptive Gaussian-process Sampling and a Case Study of M 86*
23
+
24
+ and reproduces the code used there exactly ([details](#fidelity-to-the-paper)).
25
+
26
+ The package never runs a model. It proposes parameter sets and reads back their χ², so it can drive any code: an
27
+ orbit-superposition pipeline on a cluster, a Jeans model, or a lookup in an archive.
28
+
29
+ 📖 **Documentation:** https://sagari.readthedocs.io
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install sagari # add [plot] for the example notebooks
35
+ ```
36
+
37
+ Python ≥ 3.10. Depends on NumPy, SciPy, pandas, scikit-learn, emcee and PyYAML.
38
+
39
+ ## Quick start
40
+
41
+ ```python
42
+ import sagari as sg
43
+
44
+ space = sg.ParameterSpace.from_dict({"parameters": {
45
+ "mbh": {"bounds": [1e6, 1e10], "scale": "log"},
46
+ "upsilon": {"bounds": [1.0, 10.0]},
47
+ "v_c": {"bounds": [0.0, 500.0]},
48
+ "inclination": {"value": 60.0}, # fixed
49
+ }})
50
+
51
+ def my_model(design): # a DataFrame of parameter sets -> one chi2 per row
52
+ ... # (NaN for a model that failed)
53
+
54
+ c = sg.Campaign.create("my_campaign", space, converge_on=["mbh", "upsilon"])
55
+ results = c.run(my_model) # Sobol design, search rounds, stopping rule, error-bar rounds
56
+
57
+ gp, _, _ = c.surrogate(results) # the final surrogate of the chi2 surface
58
+ post = sg.posterior.sample_posterior(gp, space)
59
+ ```
60
+
61
+ ## On a cluster
62
+
63
+ When each model is a batch job, the campaign lives in a directory and moves one round at a time:
64
+
65
+ ```bash
66
+ sagari init my_campaign --space space.yaml --converge-on mbh,upsilon
67
+ sagari next my_campaign # writes my_campaign/rounds/round_000.csv
68
+ # ... run the models of the round with your own code; add model_id,chi2 rows to results.csv ...
69
+ sagari next my_campaign --results results.csv # the next round (search or error bars), or "done"
70
+ sagari status my_campaign --results results.csv # ranges per round, stopping rule, out-of-sample checks
71
+ sagari posterior my_campaign --results results.csv --out posterior --profiles
72
+ ```
73
+
74
+ Failed models are written as `chi2 = nan` and kept: the search learns to avoid where models fail.
75
+ `examples/sco_runalls.py` connects a campaign to the Fortran pipeline of the paper (`runallt` commands file in,
76
+ `cres` files out).
77
+
78
+ ## How it works
79
+
80
+ 1. **Initial design**: a scrambled Sobol sequence over the box, continued by every later round.
81
+ 2. **Search rounds**: a Gaussian process with a Matérn 5/2 kernel and an explicit nugget for the model-to-model
82
+ scatter of χ², fitted near the minimum. A second GP fitted to every model keeps the search out of regions that
83
+ are hopeless or where models fail. Each round places 65% of its models by feasibility-weighted Expected
84
+ Improvement, with a denoised incumbent, two-scale candidates and a minimum separation, and verifies isolated
85
+ low models by their neighbours. The other 35% continues the Sobol sequence.
86
+ 3. **Stopping rule**: smoothed 16–84% ranges of the parameters you care about. The search stops when they move
87
+ by less than 0.2 of their width, after at least two refinement rounds.
88
+ 4. **Error-bar rounds**: models fill the acceptable region and the parameter profiles. Each model's χ² is
89
+ predicted before it runs, so the surrogate is tested out of sample.
90
+ 5. **Posterior**: MCMC on the final surrogate where models constrain it, averaged over the surrogate's own
91
+ uncertainty, plus Δχ² profile intervals.
92
+
93
+ The defaults are the settings adopted and validated in the paper.
94
+
95
+ ## Example notebooks
96
+
97
+ | notebook | |
98
+ | --- | --- |
99
+ | [01 Your first campaign](examples/notebooks/01_quickstart.ipynb) | a complete campaign in Python on a noisy analytic χ² |
100
+ | [02 A campaign on a cluster](examples/notebooks/02_cluster_workflow.ipynb) | round files, results tables, failures, narrowing the box, the CLI |
101
+ | [03 From the surrogate to a measurement](examples/notebooks/03_analysis.ipynb) | profiles, posteriors, two solutions, surrogate uncertainty |
102
+ | [04 Benchmark and replay](examples/notebooks/04_benchmark_and_replay.ipynb) | the paper's two-basin benchmark and a replay on an archive |
103
+
104
+ ## Fidelity to the paper
105
+
106
+ The package is a port of the campaign code used in the paper, not a reimplementation:
107
+
108
+ - `tests/test_parity.py` runs the original code and the package side by side and requires identical output at
109
+ every stage (needs the original code; see the docs).
110
+ - `tests/test_golden.py` checks the same outputs against a fixture written by the original code, and runs anywhere.
111
+ - Replays of the paper's NGC 5102 and NGC 3258 campaigns, and its two-basin benchmark
112
+ (`benchmarks/two_basin.py`), are reproduced model for model.
113
+
114
+ Exact repetition from a seed also needs the same library versions and the same number of linear-algebra threads
115
+ (`OMP_NUM_THREADS=1`).
116
+
117
+ ## Citing
118
+
119
+ Please cite Waters & Gültekin (2026) and the software (`CITATION.cff`).
120
+
121
+ ## License
122
+
123
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,129 @@
1
+ """The two-basin benchmark of the paper (Section 4.3 and Appendix B), run with sagari.
2
+
3
+ Each trial is a campaign of 256 Sobol points and five rounds of 128 (896 models) on the synthetic objective
4
+ of :mod:`sagari.benchmark`, for three samplers:
5
+
6
+ full the method of the paper (Matern 5/2 kernel, 30 sigma training window, 35% global share, plug-in
7
+ incumbent, two-scale candidates, minimum separation, verification of isolated models)
8
+ ablated the same search without its robustness measures: squared-exponential kernel, no training window,
9
+ 15% global share, the lowest model as the incumbent, no local candidates, no minimum separation
10
+ or verification, one candidate pool for the whole campaign (the feasibility weighting is kept)
11
+ sobol Sobol sampling alone
12
+
13
+ After every round it records whether the best point of the smoothed estimate lies in the deeper basin, its
14
+ M_BH error, the models within 2 sigma of the deeper minimum, and the share of local points in the failure
15
+ region. Trials use seeds 1000, 1001, ... as in the paper (100 trials: Figure 1).
16
+
17
+ python two_basin.py [n_trials] [--hopeless] [--out results/two_basin.csv] [--procs N]
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ from multiprocessing import Pool
25
+ from pathlib import Path
26
+
27
+ for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS", "VECLIB_MAXIMUM_THREADS"):
28
+ os.environ.setdefault(_v, "1")
29
+
30
+ import numpy as np # noqa: E402
31
+ import pandas as pd # noqa: E402
32
+
33
+ import sagari as sg # noqa: E402
34
+ from sagari.benchmark import NOISE, PILOT_SPACE, TwoBasin # noqa: E402
35
+
36
+ N0, NR, N_ROUNDS = 256, 128, 5
37
+ WASTE_ABOVE = 2000.0 # a local point with true Delta chi2 above this is wasted
38
+ VARIANTS = {
39
+ "full": dict(kernel="matern52", train_window=30.0, global_fraction=0.35),
40
+ "ablated": dict(kernel="rbf", train_window=None, global_fraction=0.15, incumbent="raw", n_local_candidates=0,
41
+ min_separation=0.0, verify=False, redraw_candidates=False),
42
+ "sobol": None,
43
+ }
44
+
45
+
46
+ def trial(args):
47
+ variant, seed, hopeless = args
48
+ obj = TwoBasin(hopeless=hopeless)
49
+ space = PILOT_SPACE
50
+ rng = np.random.default_rng(seed)
51
+ sob = sg.SobolGlobalDesign(space.dim, seed)
52
+ d = sg.initial_design(space, N0, sob)
53
+ table = d[space.free_params].copy()
54
+ table["chi2"] = obj(d, rng)
55
+ rows, waste = [], []
56
+ for rnd in range(N_ROUNDS + 1):
57
+ if rnd > 0:
58
+ if VARIANTS[variant] is None:
59
+ d = sg.initial_design(space, NR, sob)
60
+ else:
61
+ # noise_diagnostic=False: the band for verifying isolated models is twice the sd of the 20 lowest
62
+ # chi2, as in the benchmark runs of the paper
63
+ d = sg.propose_round(table, space, NR, sob, seed=seed, noise_diagnostic=False, **VARIANTS[variant])
64
+ loc = (d["stage"] != "sobol_global_continuation").values
65
+ if loc.any():
66
+ waste.append(float(((obj.true(space.to_unit(d[loc]))[0] - 1000) > WASTE_ABOVE).mean()))
67
+ new = d[space.free_params].copy()
68
+ new["chi2"] = obj(d, rng)
69
+ table = pd.concat([table, new], ignore_index=True)
70
+ # The runs of the paper drew a search pool for a diagnostic not reported here from the generator that also
71
+ # draws the chi2 scatter; draw the same numbers so that every later round sees identical scatter.
72
+ rng.random((20000, space.dim))
73
+ for _ in range(50):
74
+ rng.standard_normal((20, space.dim))
75
+ f, in_n = obj.true(space.to_unit(table))
76
+ row = dict(variant=variant, seed=seed, round=rnd, n_models=len(table), n_failed=int(table["chi2"].isna().sum()),
77
+ waste=float(np.mean(waste)) if waste else np.nan, n_in_N=int((((f - 1000) < 2 * NOISE) & in_n).sum()))
78
+ est = sg.smoothed_estimate(table, space, seed=seed, kernel="matern52", train_window=30.0)
79
+ if est is not None:
80
+ row.update(est_found=obj.found(est), est_mbh_err=obj.mbh_error(est),
81
+ est_mbh_covers=bool(est["mbh"][1] <= 8.0e8 <= est["mbh"][2]))
82
+ rows.append(row)
83
+ return rows
84
+
85
+
86
+ def wilson(k, n, z=1.96):
87
+ p = k / n
88
+ c = (p + z * z / (2 * n)) / (1 + z * z / n)
89
+ h = z * np.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
90
+ return c - h, c + h
91
+
92
+
93
+ def main():
94
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
95
+ ap.add_argument("n_trials", type=int, nargs="?", default=20)
96
+ ap.add_argument("--hopeless", action="store_true", help="add the region of failed and very-high-chi2 models")
97
+ ap.add_argument("--variants", default="full,ablated,sobol")
98
+ ap.add_argument("--out", default=None)
99
+ ap.add_argument("--procs", type=int, default=None)
100
+ a = ap.parse_args()
101
+ out = Path(a.out or f"results/two_basin_{'hopeless' if a.hopeless else 'plain'}.csv")
102
+ out.parent.mkdir(parents=True, exist_ok=True)
103
+ part = out.with_suffix(".jsonl")
104
+ done = set()
105
+ if part.exists(): # finished trials are kept, so an interrupted run loses nothing
106
+ done = {(r[0]["variant"], r[0]["seed"]) for r in map(json.loads, part.read_text().splitlines())}
107
+ jobs = [(v, 1000 + s, a.hopeless) for v in a.variants.split(",") for s in range(a.n_trials)
108
+ if (v, 1000 + s) not in done]
109
+ print(f"{len(jobs)} trials to run ({len(done)} already in {part})", flush=True)
110
+ with Pool(a.procs) as pool:
111
+ for rs in pool.imap_unordered(trial, jobs):
112
+ with open(part, "a") as f:
113
+ f.write(json.dumps(rs, default=float) + "\n")
114
+ print(f"{rs[0]['variant']} {rs[0]['seed']}: found={rs[-1].get('est_found')}", flush=True)
115
+ rows = [r for rs in map(json.loads, part.read_text().splitlines()) for r in rs if r["seed"] < 1000 + a.n_trials]
116
+ df = pd.DataFrame(rows)
117
+ df.to_csv(out, index=False)
118
+ fin = df[df["n_models"] == N0 + N_ROUNDS * NR]
119
+ print(f"\nat {N0 + N_ROUNDS * NR} models ({'with' if a.hopeless else 'without'} the failure region):")
120
+ for v, g in fin.groupby("variant"):
121
+ k, n = int(g["est_found"].sum()), len(g)
122
+ lo, hi = wilson(k, n)
123
+ print(f" {v:8s} deeper basin found in {k}/{n} ({100 * lo:.0f}-{100 * hi:.0f}%), median M_BH error "
124
+ f"{g['est_mbh_err'].median():.2f} dex, median models within 2 sigma of it {g['n_in_N'].median():.0f}, "
125
+ f"local points wasted {100 * g['waste'].mean():.0f}%")
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
@@ -0,0 +1,77 @@
1
+ # From the final surrogate to a measurement
2
+
3
+ The constraints come from the **inference surrogate**: a GP fitted to every model inside the training window,
4
+ with a nugget for the model-to-model scatter. Do not use the lowest model. With scatter, the lowest of many
5
+ models lies well below the smooth minimum (in M86, 17 below).
6
+
7
+ ```python
8
+ import sagari as sg
9
+ from sagari import posterior as po
10
+
11
+ c = sg.Campaign("my_campaign")
12
+ gp, chi2_min, threshold = c.surrogate("results.csv")
13
+ print(gp.scatter) # sigma_chi2, the fitted model-to-model scatter
14
+ ```
15
+
16
+ Outside a campaign directory, use `sg.inference_gp(table, space)` on any table of models.
17
+
18
+ ## Profiles: Δχ² intervals
19
+
20
+ ```python
21
+ prof = po.profiles(gp, space, seed=c.seed) # every free parameter
22
+ iv = po.profile_intervals(prof, space, levels=(1.0, 4.0, 10.0)) # {param: {level: [(lo, hi), ...]}}
23
+ ```
24
+
25
+ A profile minimizes the surrogate mean over all other parameters at each value of one. Points are trusted only
26
+ where models constrain the surrogate (latent sd ≤ 2σ). Intervals can have several pieces when the region is
27
+ disconnected. The ends move when the scatter is redrawn; `profile_interval_bootstrap` refits the surrogate to
28
+ parametric-bootstrap copies and gives their spread.
29
+
30
+ ## The posterior
31
+
32
+ ```python
33
+ post = po.sample_posterior(gp, space) # emcee: 32 walkers, 25000 steps, 5000 burn-in
34
+ post["mbh"].quantile([0.1585, 0.5, 0.8415])
35
+ ```
36
+
37
+ This samples $p\propto\exp(-\mu/2)$ with priors uniform in the unit cube (log-uniform on log axes). The posterior
38
+ is zero wherever the surrogate's latent sd exceeds $2\sigma_{\chi^2}$: there no models constrain it, and a sampler
39
+ would settle in any low value the surrogate extrapolates. The samples in the unit cube are in
40
+ `post.attrs["unit"]`.
41
+
42
+ ## Averaging over the surrogate's uncertainty
43
+
44
+ The surrogate is fitted to noisy models, so its mean is uncertain too. This matters most when two solutions
45
+ differ by less than the scatter:
46
+
47
+ ```python
48
+ idx, D, D_refit = po.surface_draws(gp, post.attrs["unit"]) # 2000 latent draws at 3000 samples (+300 refits)
49
+ lo, med, hi = po.pooled_quantiles(post["mbh"].values[idx], D) # the surrogate-marginalized posterior
50
+ shares = po.mode_shares(D, post["upsilon"].values[idx] > 7.0) # how uncertain the split between two solutions is
51
+ ```
52
+
53
+ `D` holds joint draws of the surrogate's latent error (the scatter removed, so it is not counted twice).
54
+ `D_refit` comes from refits to the models with their χ² redrawn from the scatter. Reweighting the samples by each
55
+ draw gives the posterior of that realization of the surface. Pooling the reweighted posteriors gives the
56
+ surrogate-marginalized posterior that the paper adopts.
57
+
58
+ ## Other priors
59
+
60
+ Every prior is a reweighting of the samples. For a prior uniform in $r_c$ instead of $\log r_c$:
61
+
62
+ ```python
63
+ w = post["r_c"].values[idx]
64
+ po.pooled_quantiles(post["mbh"].values[idx], D, prior_w=w)
65
+ po.mode_shares(D, select, prior_w=w)
66
+ ```
67
+
68
+ ## Checking the surrogate
69
+
70
+ - **Out of sample**: every error-bar round stores the prediction made before each model ran;
71
+ `sagari status` reports the rms of $z=(\chi^2-\mu)/s$ (≈1 for a calibrated surrogate) and the fractions
72
+ beyond ±2. `sg.out_of_sample(round_table, chi2)` computes it directly.
73
+ - **Leave-one-out**: `po.loo(gp)` returns the closed-form leave-one-out residuals of the training models.
74
+ - **The scatter**: `gp.scatter` should be stable once the region of the minimum is sampled.
75
+ `sg.errorbars.window_check(gp)` compares it with the leave-one-out rms near the floor.
76
+
77
+ The [analysis notebook](notebooks/03_analysis.ipynb) runs all of this on a two-solution problem.
@@ -0,0 +1,32 @@
1
+ API
2
+ ===
3
+
4
+ .. automodule:: sagari.space
5
+ :members:
6
+
7
+ .. automodule:: sagari.sobol
8
+ :members:
9
+
10
+ .. automodule:: sagari.acquisition
11
+ :members:
12
+
13
+ .. automodule:: sagari.monitor
14
+ :members:
15
+
16
+ .. automodule:: sagari.errorbars
17
+ :members:
18
+
19
+ .. automodule:: sagari.posterior
20
+ :members:
21
+
22
+ .. automodule:: sagari.campaign
23
+ :members:
24
+
25
+ .. automodule:: sagari.gp
26
+ :members:
27
+
28
+ .. automodule:: sagari.replay
29
+ :members:
30
+
31
+ .. automodule:: sagari.benchmark
32
+ :members:
@@ -0,0 +1,2 @@
1
+ ```{include} ../CHANGELOG.md
2
+ ```
@@ -0,0 +1,8 @@
1
+ # Citing
2
+
3
+ If you use SAGARI, please cite the paper that describes and validates the method:
4
+
5
+ > Waters, T. K., & Gültekin, K. 2026, *Schwarzschild Orbit-library Modeling with an Order of Magnitude Fewer
6
+ > Models: Adaptive Gaussian-process Sampling and a Case Study of M 86*, ApJ, submitted
7
+
8
+ and the software itself (`CITATION.cff` in the repository; the archived version on Zenodo).