symphony-dt 2.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.
Files changed (57) hide show
  1. symphony_dt/__init__.py +61 -0
  2. symphony_dt/__main__.py +5 -0
  3. symphony_dt/_about.py +53 -0
  4. symphony_dt/_version.py +3 -0
  5. symphony_dt/calibration/__init__.py +40 -0
  6. symphony_dt/calibration/ppc.py +114 -0
  7. symphony_dt/calibration/sbc.py +385 -0
  8. symphony_dt/cli.py +347 -0
  9. symphony_dt/config.py +101 -0
  10. symphony_dt/constants.py +84 -0
  11. symphony_dt/data/__init__.py +66 -0
  12. symphony_dt/data/dataset.py +335 -0
  13. symphony_dt/data/envelope.py +184 -0
  14. symphony_dt/data/harmonise.py +113 -0
  15. symphony_dt/data/instruments.py +140 -0
  16. symphony_dt/data/simulate.py +505 -0
  17. symphony_dt/data/stan_data.py +426 -0
  18. symphony_dt/diagnostics.py +424 -0
  19. symphony_dt/errors.py +57 -0
  20. symphony_dt/evaluate/__init__.py +61 -0
  21. symphony_dt/evaluate/metrics.py +308 -0
  22. symphony_dt/evaluate/scoring.py +625 -0
  23. symphony_dt/evaluate/spec.py +210 -0
  24. symphony_dt/fit.py +282 -0
  25. symphony_dt/forecast.py +683 -0
  26. symphony_dt/governance/__init__.py +35 -0
  27. symphony_dt/governance/cards.py +85 -0
  28. symphony_dt/governance/manifest.py +212 -0
  29. symphony_dt/governance/provenance.py +135 -0
  30. symphony_dt/governance/reproduce.py +69 -0
  31. symphony_dt/governance/tripod.py +120 -0
  32. symphony_dt/io/__init__.py +33 -0
  33. symphony_dt/io/adapters.py +198 -0
  34. symphony_dt/io/mapping.py +339 -0
  35. symphony_dt/io/mappings/fhir.yaml +21 -0
  36. symphony_dt/io/mappings/generic_long.yaml +17 -0
  37. symphony_dt/io/mappings/omop.yaml +19 -0
  38. symphony_dt/io/mappings/ppmi.yaml +26 -0
  39. symphony_dt/io/mappings/sdtm.yaml +18 -0
  40. symphony_dt/io/mappings/wide.yaml +15 -0
  41. symphony_dt/io/posterior.py +32 -0
  42. symphony_dt/io/tabular.py +155 -0
  43. symphony_dt/labels.py +89 -0
  44. symphony_dt/models/__init__.py +233 -0
  45. symphony_dt/models/pd_v2/__init__.py +55 -0
  46. symphony_dt/models/pd_v2/card.md +93 -0
  47. symphony_dt/models/pd_v2/conditional.stan +198 -0
  48. symphony_dt/models/pd_v2/forecast_gq.stan +176 -0
  49. symphony_dt/models/pd_v2/model.stan +289 -0
  50. symphony_dt/plots.py +253 -0
  51. symphony_dt/py.typed +0 -0
  52. symphony_dt/report.py +115 -0
  53. symphony_dt-2.0.0.dist-info/METADATA +166 -0
  54. symphony_dt-2.0.0.dist-info/RECORD +57 -0
  55. symphony_dt-2.0.0.dist-info/WHEEL +4 -0
  56. symphony_dt-2.0.0.dist-info/entry_points.txt +2 -0
  57. symphony_dt-2.0.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,61 @@
1
+ """symphony-dt: Bayesian hierarchical state-space digital twins for longitudinal clinical cohorts.
2
+
3
+ Importing the package prints nothing and loads no heavy dependency. Subpackages load on first use:
4
+ `symphony_dt.data`, `symphony_dt.io`, `symphony_dt.models`, `symphony_dt.fit`, `symphony_dt.diagnostics`,
5
+ `symphony_dt.forecast`, `symphony_dt.evaluate`, `symphony_dt.calibration`, `symphony_dt.governance`,
6
+ `symphony_dt.report`, `symphony_dt.plots`. Call `symphony_dt.about()` for the version and citation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ from typing import Any
13
+
14
+ from symphony_dt._version import __version__
15
+
16
+ __all__ = ["__version__", "about", "validation_status"]
17
+
18
+ _SUBMODULES = {
19
+ "data",
20
+ "io",
21
+ "models",
22
+ "fit",
23
+ "diagnostics",
24
+ "forecast",
25
+ "evaluate",
26
+ "calibration",
27
+ "governance",
28
+ "report",
29
+ "plots",
30
+ "cli",
31
+ "constants",
32
+ "config",
33
+ "labels",
34
+ "errors",
35
+ }
36
+
37
+
38
+ def about(file: Any = None) -> str:
39
+ """Print and return the logo, version, author, citation and documentation address.
40
+
41
+ Validation: TESTED.
42
+ """
43
+ from symphony_dt._about import about as _about
44
+
45
+ return _about(file=file)
46
+
47
+
48
+ def validation_status() -> dict[str, str]:
49
+ """Return the validation label of every public function and class.
50
+
51
+ Validation: TESTED.
52
+ """
53
+ from symphony_dt.labels import validation_status as _status
54
+
55
+ return _status()
56
+
57
+
58
+ def __getattr__(name: str) -> Any:
59
+ if name in _SUBMODULES:
60
+ return importlib.import_module(f"symphony_dt.{name}")
61
+ raise AttributeError(f"module 'symphony_dt' has no attribute {name!r}")
@@ -0,0 +1,5 @@
1
+ """Run the CLI with python -m symphony_dt."""
2
+
3
+ from symphony_dt.cli import main
4
+
5
+ main()
symphony_dt/_about.py ADDED
@@ -0,0 +1,53 @@
1
+ """The `about` banner: logo, version, author, citation and documentation address."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any, TextIO
7
+
8
+ from symphony_dt._version import __version__
9
+ from symphony_dt.labels import validation
10
+
11
+ __all__ = ["ASCII_LOGO", "CITATION", "DOCS_URL", "about"]
12
+
13
+ DOCS_URL = "https://a7med7emedan.github.io/symphony-dt/"
14
+
15
+ #: The mark: three staff lines that turn into a forecast trajectory inside an uncertainty band.
16
+ ASCII_LOGO = r"""
17
+ .:::
18
+ -----------. .:::::/
19
+ ------------(o)---------.__..--''::::::
20
+ -----------' '::::::::
21
+ '':::
22
+ s y m p h o n y - d t
23
+ """
24
+
25
+ CITATION = (
26
+ "Hemedan A. symphony-dt: Bayesian digital twins of Parkinson's disease progression, "
27
+ f"version {__version__}. https://github.com/a7med7emedan/symphony-dt"
28
+ )
29
+
30
+
31
+ @validation("TESTED")
32
+ def about(file: TextIO | Any = None) -> str:
33
+ """Print the logo, version, author, citation and documentation address, and return the text.
34
+
35
+ Pass `file` to print elsewhere than standard output.
36
+
37
+ Validation: TESTED.
38
+ """
39
+ text = "\n".join(
40
+ [
41
+ ASCII_LOGO.rstrip("\n"),
42
+ "",
43
+ f" symphony-dt {__version__}",
44
+ " Developed by Ahmed Hemedan",
45
+ "",
46
+ f" Cite: {CITATION}",
47
+ " Validation manuscript in preparation.",
48
+ f" Docs: {DOCS_URL}",
49
+ "",
50
+ ]
51
+ )
52
+ print(text, file=file if file is not None else sys.stdout)
53
+ return text
@@ -0,0 +1,3 @@
1
+ """The package version, read by hatchling at build time."""
2
+
3
+ __version__ = "2.0.0"
@@ -0,0 +1,40 @@
1
+ """Calibration: simulation-based calibration and posterior predictive checks."""
2
+
3
+ from symphony_dt.calibration.ppc import boundary_statistics, pit_values, ppc_coverage, ppc_summary
4
+ from symphony_dt.calibration.sbc import (
5
+ CHI2_ALPHA,
6
+ N_BINS,
7
+ RANK_DRAWS,
8
+ SBCDesign,
9
+ SBCResult,
10
+ SBCVerdict,
11
+ chi_squared_uniformity,
12
+ judge,
13
+ ks_uniformity,
14
+ pd_v2_prior_design,
15
+ rank_histogram_bands,
16
+ run_sbc,
17
+ sbc_rank,
18
+ thin_for_rank,
19
+ )
20
+
21
+ __all__ = [
22
+ "CHI2_ALPHA",
23
+ "N_BINS",
24
+ "RANK_DRAWS",
25
+ "SBCDesign",
26
+ "SBCResult",
27
+ "SBCVerdict",
28
+ "boundary_statistics",
29
+ "chi_squared_uniformity",
30
+ "judge",
31
+ "ks_uniformity",
32
+ "pd_v2_prior_design",
33
+ "pit_values",
34
+ "ppc_coverage",
35
+ "ppc_summary",
36
+ "rank_histogram_bands",
37
+ "run_sbc",
38
+ "sbc_rank",
39
+ "thin_for_rank",
40
+ ]
@@ -0,0 +1,114 @@
1
+ """Posterior predictive checks: PIT values, boundary statistics and interval coverage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ from numpy.typing import ArrayLike
10
+
11
+ from symphony_dt import constants as C
12
+ from symphony_dt.labels import validation
13
+
14
+ __all__ = ["boundary_statistics", "pit_values", "ppc_coverage", "ppc_summary"]
15
+
16
+
17
+ @validation("TESTED")
18
+ def pit_values(draws: ArrayLike, y: ArrayLike, *, seed: int = 0) -> np.ndarray:
19
+ """Return randomised probability integral transform values, one per observation.
20
+
21
+ PIT = P(X < y) + U x P(X = y), with U uniform, so ties at a scale boundary do not pile up.
22
+ `draws` is (draws, observations). Uniform PIT values indicate calibration.
23
+
24
+ Validation: TESTED.
25
+ """
26
+ x = np.asarray(draws, float)
27
+ obs = np.asarray(y, float)
28
+ rng = np.random.default_rng(seed)
29
+ below = (x < obs).mean(axis=0)
30
+ equal = (x == obs).mean(axis=0)
31
+ return np.asarray(below + rng.uniform(size=obs.size) * equal, float)
32
+
33
+
34
+ @validation("TESTED")
35
+ def boundary_statistics(
36
+ draws: ArrayLike, y: ArrayLike, lower: float, upper: float, *, round_to_integer: bool = False
37
+ ) -> dict[str, Any]:
38
+ """Compare the observed share of values at each scale boundary with the replicated share.
39
+
40
+ Returns the observed share, the replicated mean share and its 95% interval across draws, at the
41
+ floor and at the ceiling.
42
+
43
+ Validation: TESTED.
44
+ """
45
+ x = np.asarray(draws, float)
46
+ obs = np.asarray(y, float)
47
+ if round_to_integer:
48
+ x = np.round(x)
49
+ out: dict[str, Any] = {}
50
+ for name, bound, below in (("floor", lower, True), ("ceiling", upper, False)):
51
+ rep = (x <= bound).mean(axis=1) if below else (x >= bound).mean(axis=1)
52
+ seen = float((obs <= bound).mean() if below else (obs >= bound).mean())
53
+ lo, hi = np.quantile(rep, [0.025, 0.975])
54
+ out[name] = {
55
+ "observed": seen,
56
+ "replicated_mean": float(rep.mean()),
57
+ "replicated_95": [float(lo), float(hi)],
58
+ "inside": bool(lo <= seen <= hi),
59
+ }
60
+ return out
61
+
62
+
63
+ @validation("TESTED")
64
+ def ppc_coverage(draws: ArrayLike, y: ArrayLike, levels: Sequence[float] = (0.5, 0.95)) -> dict[str, float]:
65
+ """Return the share of observations inside the inclusive central predictive interval per level.
66
+
67
+ Validation: TESTED.
68
+ """
69
+ x = np.asarray(draws, float)
70
+ obs = np.asarray(y, float)
71
+ out = {}
72
+ for level in levels:
73
+ tail = (1 - level) / 2
74
+ lo, hi = np.quantile(x, [tail, 1 - tail], axis=0)
75
+ out[f"{level:g}"] = float(((obs >= lo) & (obs <= hi)).mean())
76
+ return out
77
+
78
+
79
+ @validation("TESTED")
80
+ def ppc_summary(
81
+ draws: ArrayLike,
82
+ stan_data: Mapping[str, Any],
83
+ *,
84
+ round_endpoints: Sequence[int] = (1, 2),
85
+ pit_bins: int = 10,
86
+ ) -> dict[str, Any]:
87
+ """Summarise posterior predictive draws per endpoint: coverage, boundaries and a PIT histogram.
88
+
89
+ `draws` is (draws, n_obs), for example `y_rep_c` from the GQ program run on the fitted data.
90
+ Endpoints in `round_endpoints` are rounded to integers before the boundary comparison, as the
91
+ observed scores are integers. Aggregates only.
92
+
93
+ Validation: TESTED.
94
+ """
95
+ x = np.asarray(draws, float)
96
+ y = np.asarray(stan_data["y"], float)
97
+ ks = np.asarray(stan_data["obs_k"], int)
98
+ lower, upper = stan_data["Lk"], stan_data["Uk"]
99
+ out: dict[str, Any] = {}
100
+ for k in (1, 2, 3):
101
+ m = ks == k
102
+ if not m.any():
103
+ continue
104
+ pit = pit_values(x[:, m], y[m])
105
+ hist, _ = np.histogram(pit, bins=np.linspace(0, 1, pit_bins + 1))
106
+ out[C.ENDPOINTS[k - 1]] = {
107
+ "n": int(m.sum()),
108
+ "coverage": ppc_coverage(x[:, m], y[m]),
109
+ "boundaries": boundary_statistics(
110
+ x[:, m], y[m], float(lower[k - 1]), float(upper[k - 1]), round_to_integer=k in round_endpoints
111
+ ),
112
+ "pit_histogram": [int(c) for c in hist],
113
+ }
114
+ return out
@@ -0,0 +1,385 @@
1
+ """Simulation-based calibration (SBC): ranks, uniformity tests and a judge with multiplicity control.
2
+
3
+ Talts et al. (2018) take the rank of the true value among L posterior draws; under a correctly
4
+ calibrated sampler it is uniform on 0 to L. The study judged each global parameter by a chi-squared
5
+ test over 20 equal bins and corrected for the number of parameters with Bonferroni, because the
6
+ acceptance rule fails on any single rejection. `judge` makes that choice explicit, offers
7
+ Benjamini-Hochberg and no correction, and always reports the family-wise false-rejection risk of an
8
+ uncorrected judge, 1 - (1 - alpha)^m.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Mapping, Sequence
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Literal
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+ from numpy.typing import ArrayLike, NDArray
20
+
21
+ from symphony_dt.labels import validation
22
+
23
+ __all__ = [
24
+ "CHI2_ALPHA",
25
+ "N_BINS",
26
+ "RANK_DRAWS",
27
+ "SBCDesign",
28
+ "SBCResult",
29
+ "SBCVerdict",
30
+ "chi_squared_uniformity",
31
+ "judge",
32
+ "ks_uniformity",
33
+ "pd_v2_prior_design",
34
+ "rank_histogram_bands",
35
+ "run_sbc",
36
+ "sbc_rank",
37
+ "thin_for_rank",
38
+ ]
39
+
40
+ #: Family-wise level of the SBC judge.
41
+ CHI2_ALPHA = 0.01
42
+ #: Number of equal rank bins.
43
+ N_BINS = 20
44
+ #: Draws kept per replicate for a rank; (RANK_DRAWS + 1) is a multiple of N_BINS.
45
+ RANK_DRAWS = 199
46
+
47
+
48
+ @validation("VALIDATED")
49
+ def sbc_rank(draws: ArrayLike, truth: float) -> int:
50
+ """Return the SBC rank: how many posterior draws fall below the truth.
51
+
52
+ Validation: VALIDATED.
53
+ """
54
+ return int(np.sum(np.asarray(draws) < truth))
55
+
56
+
57
+ @validation("VALIDATED")
58
+ def thin_for_rank(draws: ArrayLike, target: int = RANK_DRAWS) -> NDArray[Any]:
59
+ """Thin to `target` draws at uniform spacing, so the rank uses near-independent values.
60
+
61
+ Validation: VALIDATED.
62
+ """
63
+ arr = np.asarray(draws).ravel()
64
+ if arr.size <= target:
65
+ return np.asarray(arr)
66
+ index = np.linspace(0, arr.size - 1, target).round().astype(int)
67
+ return np.asarray(arr[index])
68
+
69
+
70
+ @validation("VALIDATED")
71
+ def chi_squared_uniformity(
72
+ ranks: ArrayLike, n_draws: int, bins: int = N_BINS, alpha: float | None = None
73
+ ) -> dict[str, Any]:
74
+ """Test rank uniformity by chi-squared over `bins` equal bins.
75
+
76
+ `alpha` is the per-parameter threshold; None uses the family level, which is right only for a
77
+ single test. The uncorrected p-value is always returned.
78
+
79
+ Validation: VALIDATED.
80
+ """
81
+ from scipy.stats import chisquare
82
+
83
+ r = np.asarray(ranks)
84
+ if (n_draws + 1) % bins != 0:
85
+ raise ValueError(f"{n_draws + 1} possible ranks do not divide into {bins} equal bins")
86
+ edges = np.linspace(0, n_draws + 1, bins + 1)
87
+ counts, _ = np.histogram(r, bins=edges)
88
+ expected = np.full(bins, len(r) / bins)
89
+ stat, p = chisquare(counts, expected)
90
+ threshold = CHI2_ALPHA if alpha is None else float(alpha)
91
+ return {
92
+ "chi2": float(stat),
93
+ "p": float(p),
94
+ "bins": bins,
95
+ "n_ranks": len(r),
96
+ "uniform": bool(p >= threshold),
97
+ "alpha_applied": threshold,
98
+ "uniform_uncorrected": bool(p >= CHI2_ALPHA),
99
+ "counts": [int(c) for c in counts],
100
+ }
101
+
102
+
103
+ @validation("TESTED")
104
+ def ks_uniformity(ranks: ArrayLike, n_draws: int) -> dict[str, float]:
105
+ """Kolmogorov-Smirnov test of ranks against the uniform distribution on 0 to n_draws.
106
+
107
+ Ranks are mapped to (rank + 0.5) / (n_draws + 1), the midpoints of their cells, which makes the
108
+ test slightly conservative for discrete ranks.
109
+
110
+ Validation: TESTED.
111
+ """
112
+ from scipy.stats import kstest
113
+
114
+ u = (np.asarray(ranks, float) + 0.5) / (n_draws + 1)
115
+ res = kstest(u, "uniform")
116
+ return {"ks": float(res.statistic), "p": float(res.pvalue), "n_ranks": int(u.size)}
117
+
118
+
119
+ @validation("TESTED")
120
+ def rank_histogram_bands(n_ranks: int, bins: int = N_BINS, level: float = 0.99) -> tuple[float, float]:
121
+ """Return the binomial band for one bin count of a uniform rank histogram.
122
+
123
+ Each bin count is Binomial(n_ranks, 1 / bins); the band holds its central `level` mass.
124
+
125
+ Validation: TESTED.
126
+ """
127
+ from scipy.stats import binom
128
+
129
+ tail = (1 - level) / 2
130
+ return (float(binom.ppf(tail, n_ranks, 1 / bins)), float(binom.ppf(1 - tail, n_ranks, 1 / bins)))
131
+
132
+
133
+ @validation("TESTED")
134
+ @dataclass
135
+ class SBCVerdict:
136
+ """The judge's verdict on a family of uniformity tests.
137
+
138
+ Validation: TESTED.
139
+ """
140
+
141
+ method: str
142
+ alpha: float
143
+ m: int
144
+ rejected: list[str]
145
+ adjusted_threshold: dict[str, float]
146
+ fwer_uncorrected: float
147
+ p_values: dict[str, float]
148
+
149
+ @property
150
+ def passed(self) -> bool:
151
+ """True when no parameter is rejected."""
152
+ return not self.rejected
153
+
154
+ def to_dict(self) -> dict[str, Any]:
155
+ """Return a JSON-serialisable record.
156
+
157
+ Validation: TESTED.
158
+ """
159
+ return {
160
+ "method": self.method,
161
+ "alpha": self.alpha,
162
+ "m": self.m,
163
+ "passed": self.passed,
164
+ "rejected": self.rejected,
165
+ "fwer_uncorrected": self.fwer_uncorrected,
166
+ "adjusted_threshold": self.adjusted_threshold,
167
+ "p_values": self.p_values,
168
+ }
169
+
170
+
171
+ @validation("TESTED")
172
+ def judge(
173
+ per_parameter_p: Mapping[str, float],
174
+ *,
175
+ alpha: float = CHI2_ALPHA,
176
+ method: Literal["bonferroni", "bh", "none"] = "bonferroni",
177
+ ) -> SBCVerdict:
178
+ """Judge a family of per-parameter uniformity p-values with explicit multiplicity control.
179
+
180
+ `bonferroni` controls the family-wise error rate at `alpha` and is right when any single
181
+ rejection fails the campaign. `bh` (Benjamini-Hochberg) controls the false discovery rate.
182
+ `none` applies `alpha` to each test; its family-wise false-rejection risk under independence,
183
+ 1 - (1 - alpha)^m, is reported whatever the method.
184
+
185
+ Validation: TESTED.
186
+ """
187
+ names = sorted(per_parameter_p)
188
+ m = len(names)
189
+ if m == 0:
190
+ raise ValueError("no p-values to judge")
191
+ p = np.array([per_parameter_p[n] for n in names], float)
192
+ if method == "bonferroni":
193
+ thr = dict.fromkeys(names, alpha / m)
194
+ rejected = [n for n, v in zip(names, p, strict=True) if v < alpha / m]
195
+ elif method == "none":
196
+ thr = dict.fromkeys(names, alpha)
197
+ rejected = [n for n, v in zip(names, p, strict=True) if v < alpha]
198
+ elif method == "bh":
199
+ order = np.argsort(p, kind="mergesort")
200
+ crit = alpha * np.arange(1, m + 1) / m
201
+ below = np.nonzero(p[order] <= crit)[0]
202
+ cut = int(np.max(below)) + 1 if below.size else 0
203
+ rejected = sorted(names[i] for i in order[:cut])
204
+ thr = {names[i]: float(crit[rank]) for rank, i in enumerate(order)}
205
+ else:
206
+ raise ValueError(f"unknown method {method!r}")
207
+ return SBCVerdict(
208
+ method=method,
209
+ alpha=alpha,
210
+ m=m,
211
+ rejected=rejected,
212
+ adjusted_threshold=thr,
213
+ fwer_uncorrected=float(1 - (1 - alpha) ** m),
214
+ p_values={n: float(v) for n, v in zip(names, p, strict=True)},
215
+ )
216
+
217
+
218
+ @validation("TESTED")
219
+ @dataclass
220
+ class SBCDesign:
221
+ """An SBC design: how to draw a truth and data, and how to fit.
222
+
223
+ `generator(rng)` returns (data, truth) with truth a dict of named scalars. `fitter(data, seed)`
224
+ returns a dict of named posterior draws (one array per parameter) or None for a failed fit.
225
+ For the posterior regime, the fitting prior must equal the generating distribution; set
226
+ `fitting_prior_matches_generator=True` only when that is true by construction.
227
+
228
+ Validation: TESTED. The posterior regime is EXPERIMENTAL.
229
+ """
230
+
231
+ generator: Callable[[np.random.Generator], tuple[Any, dict[str, float]]]
232
+ fitter: Callable[[Any, int], dict[str, NDArray[Any]] | None]
233
+ regime: Literal["prior", "posterior"] = "prior"
234
+ fitting_prior_matches_generator: bool = True
235
+ rank_draws: int = RANK_DRAWS
236
+ bins: int = N_BINS
237
+
238
+ def __post_init__(self) -> None:
239
+ if self.regime == "posterior" and not self.fitting_prior_matches_generator:
240
+ raise ValueError(
241
+ "posterior-regime SBC is valid only when the fitting prior equals the "
242
+ "generating distribution; set fitting_prior_matches_generator=True "
243
+ "once that holds"
244
+ )
245
+ if (self.rank_draws + 1) % self.bins:
246
+ raise ValueError("rank_draws + 1 must be a multiple of bins")
247
+
248
+
249
+ @validation("TESTED")
250
+ @dataclass
251
+ class SBCResult:
252
+ """Ranks per replicate and parameter, failures, uniformity tests and the verdict.
253
+
254
+ Validation: TESTED.
255
+ """
256
+
257
+ ranks: pd.DataFrame
258
+ failed: list[int]
259
+ tests: dict[str, dict[str, Any]]
260
+ verdict: SBCVerdict
261
+ ks: dict[str, dict[str, float]] = field(default_factory=dict)
262
+
263
+ def to_dict(self) -> dict[str, Any]:
264
+ """Return a JSON-serialisable summary.
265
+
266
+ Validation: TESTED.
267
+ """
268
+ return {
269
+ "n_replicates": len(self.ranks),
270
+ "failed_replicates": self.failed,
271
+ "verdict": self.verdict.to_dict(),
272
+ "tests": self.tests,
273
+ "ks": self.ks,
274
+ }
275
+
276
+
277
+ @validation("TESTED")
278
+ def run_sbc(
279
+ design: SBCDesign,
280
+ *,
281
+ n_replicates: int,
282
+ seed: int = 0,
283
+ method: Literal["bonferroni", "bh", "none"] = "bonferroni",
284
+ alpha: float = CHI2_ALPHA,
285
+ ) -> SBCResult:
286
+ """Run an SBC campaign: draw, fit, rank, then judge every parameter.
287
+
288
+ A failed fit is recorded by index and excluded from the ranks; it is never refitted until it
289
+ passes, which would select on the outcome. The share of failures belongs in any report.
290
+
291
+ Validation: TESTED.
292
+ """
293
+ rng = np.random.default_rng(seed)
294
+ rows: list[dict[str, Any]] = []
295
+ failed: list[int] = []
296
+ for rep in range(n_replicates):
297
+ data, truth = design.generator(rng)
298
+ draws = design.fitter(data, seed + rep + 1)
299
+ if draws is None:
300
+ failed.append(rep)
301
+ continue
302
+ row: dict[str, Any] = {"replicate": rep}
303
+ for name, value in truth.items():
304
+ if name in draws:
305
+ row[name] = sbc_rank(thin_for_rank(draws[name], design.rank_draws), value)
306
+ rows.append(row)
307
+ ranks = pd.DataFrame(rows)
308
+ names = [c for c in ranks.columns if c != "replicate"]
309
+ if not names:
310
+ raise ValueError("no replicate produced a rank")
311
+ tests = {n: chi_squared_uniformity(ranks[n].to_numpy(), design.rank_draws, design.bins) for n in names}
312
+ ks = {n: ks_uniformity(ranks[n].to_numpy(), design.rank_draws) for n in names}
313
+ verdict = judge({n: t["p"] for n, t in tests.items()}, alpha=alpha, method=method)
314
+ return SBCResult(ranks=ranks, failed=failed, tests=tests, verdict=verdict, ks=ks)
315
+
316
+
317
+ @validation("TESTED")
318
+ def pd_v2_prior_design(
319
+ n_participants: int = 60,
320
+ *,
321
+ chains: int = 4,
322
+ iter_warmup: int = 300,
323
+ iter_sampling: int = 300,
324
+ patterns: Sequence[Any] | None = None,
325
+ config: Any = None,
326
+ output_root: str | None = None,
327
+ ) -> SBCDesign:
328
+ """Return a prior-regime SBC design for pd_v2 on synthetic visit patterns.
329
+
330
+ The fitter runs `symphony_dt.fit.fit` and returns None when the convergence gates fail.
331
+
332
+ Validation: TESTED.
333
+ """
334
+ import tempfile
335
+ from pathlib import Path
336
+
337
+ from symphony_dt.config import ModelConfig
338
+ from symphony_dt.data.simulate import (
339
+ draw_prior,
340
+ flatten_theta,
341
+ simulate_replicate,
342
+ synthetic_visit_patterns,
343
+ )
344
+ from symphony_dt.data.stan_data import StanData
345
+
346
+ cfg = config or ModelConfig.primary()
347
+ pats = list(patterns) if patterns is not None else synthetic_visit_patterns(200, np.random.default_rng(1))
348
+
349
+ def generator(rng: np.random.Generator) -> tuple[Any, dict[str, float]]:
350
+ theta = draw_prior(rng, 0, cfg.hyperparameters())
351
+ data = simulate_replicate(
352
+ pats,
353
+ n_participants,
354
+ theta,
355
+ rng,
356
+ censored=cfg.censored,
357
+ state_dependent_sigma=cfg.state_dependent_sigma,
358
+ ledd_in_mu_U=cfg.ledd_in_mu_U,
359
+ sigma_xbar=cfg.sigma_xbar,
360
+ )
361
+ flat = {
362
+ k.replace("[", ".").replace("]", "").replace(",", "."): v
363
+ for k, v in flatten_theta({k: v for k, v in theta.items() if k != "Amat"}).items()
364
+ }
365
+ return StanData(data), flat
366
+
367
+ def fitter(data: Any, seed: int) -> dict[str, NDArray[Any]] | None:
368
+ from symphony_dt.fit import fit
369
+
370
+ root = Path(output_root or tempfile.mkdtemp(prefix="sdt-sbc-"))
371
+ result = fit(
372
+ data,
373
+ chains=chains,
374
+ iter_warmup=iter_warmup,
375
+ iter_sampling=iter_sampling,
376
+ seed=seed,
377
+ output_dir=root / f"rep-{seed}",
378
+ )
379
+ if not result.gates().meets_min_retained:
380
+ return None
381
+ frames = [pd.read_csv(f, comment="#") for f in result.csv_files]
382
+ frame = pd.concat(frames, ignore_index=True)
383
+ return {c: frame[c].to_numpy() for c in frame.columns if not c.endswith("__")}
384
+
385
+ return SBCDesign(generator=generator, fitter=fitter, regime="prior")