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.
- symphony_dt/__init__.py +61 -0
- symphony_dt/__main__.py +5 -0
- symphony_dt/_about.py +53 -0
- symphony_dt/_version.py +3 -0
- symphony_dt/calibration/__init__.py +40 -0
- symphony_dt/calibration/ppc.py +114 -0
- symphony_dt/calibration/sbc.py +385 -0
- symphony_dt/cli.py +347 -0
- symphony_dt/config.py +101 -0
- symphony_dt/constants.py +84 -0
- symphony_dt/data/__init__.py +66 -0
- symphony_dt/data/dataset.py +335 -0
- symphony_dt/data/envelope.py +184 -0
- symphony_dt/data/harmonise.py +113 -0
- symphony_dt/data/instruments.py +140 -0
- symphony_dt/data/simulate.py +505 -0
- symphony_dt/data/stan_data.py +426 -0
- symphony_dt/diagnostics.py +424 -0
- symphony_dt/errors.py +57 -0
- symphony_dt/evaluate/__init__.py +61 -0
- symphony_dt/evaluate/metrics.py +308 -0
- symphony_dt/evaluate/scoring.py +625 -0
- symphony_dt/evaluate/spec.py +210 -0
- symphony_dt/fit.py +282 -0
- symphony_dt/forecast.py +683 -0
- symphony_dt/governance/__init__.py +35 -0
- symphony_dt/governance/cards.py +85 -0
- symphony_dt/governance/manifest.py +212 -0
- symphony_dt/governance/provenance.py +135 -0
- symphony_dt/governance/reproduce.py +69 -0
- symphony_dt/governance/tripod.py +120 -0
- symphony_dt/io/__init__.py +33 -0
- symphony_dt/io/adapters.py +198 -0
- symphony_dt/io/mapping.py +339 -0
- symphony_dt/io/mappings/fhir.yaml +21 -0
- symphony_dt/io/mappings/generic_long.yaml +17 -0
- symphony_dt/io/mappings/omop.yaml +19 -0
- symphony_dt/io/mappings/ppmi.yaml +26 -0
- symphony_dt/io/mappings/sdtm.yaml +18 -0
- symphony_dt/io/mappings/wide.yaml +15 -0
- symphony_dt/io/posterior.py +32 -0
- symphony_dt/io/tabular.py +155 -0
- symphony_dt/labels.py +89 -0
- symphony_dt/models/__init__.py +233 -0
- symphony_dt/models/pd_v2/__init__.py +55 -0
- symphony_dt/models/pd_v2/card.md +93 -0
- symphony_dt/models/pd_v2/conditional.stan +198 -0
- symphony_dt/models/pd_v2/forecast_gq.stan +176 -0
- symphony_dt/models/pd_v2/model.stan +289 -0
- symphony_dt/plots.py +253 -0
- symphony_dt/py.typed +0 -0
- symphony_dt/report.py +115 -0
- symphony_dt-2.0.0.dist-info/METADATA +166 -0
- symphony_dt-2.0.0.dist-info/RECORD +57 -0
- symphony_dt-2.0.0.dist-info/WHEEL +4 -0
- symphony_dt-2.0.0.dist-info/entry_points.txt +2 -0
- symphony_dt-2.0.0.dist-info/licenses/LICENSE +28 -0
symphony_dt/__init__.py
ADDED
|
@@ -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}")
|
symphony_dt/__main__.py
ADDED
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
|
symphony_dt/_version.py
ADDED
|
@@ -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")
|