dfbayes 0.1.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.
dfbayes/__init__.py ADDED
@@ -0,0 +1,93 @@
1
+ """Distribution-free Bayesian procedures, ported from R's DFBA 0.1.0.
2
+
3
+ Phase 1 covers the eleven closed-form core functions. Phase 2 adds the
4
+ simulation helpers ``dfba_sim_data``, ``dfba_power_curve`` and
5
+ ``dfba_bayes_vs_t_power``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .beta import dfba_beta_bayes_factor, dfba_beta_descriptive
11
+ from .binomial import dfba_binomial
12
+ from .concordance import dfba_bivariate_concordance, dfba_gamma
13
+ from .contrast import dfba_beta_contrast
14
+ from .mann_whitney import dfba_mann_whitney
15
+ from .mcnemar import dfba_mcnemar
16
+ from .median_test import dfba_median_test
17
+ from .results import (
18
+ BetaContrastOut,
19
+ BetaDescriptiveOut,
20
+ BetaOutputDF,
21
+ BinomialOut,
22
+ BivariateConcordanceOut,
23
+ BivariateConcordanceStarOut,
24
+ DFBAResult,
25
+ GammaOut,
26
+ IntervalBFOut,
27
+ MannWhitneyLargeOut,
28
+ MannWhitneySmallOut,
29
+ McnemarOut,
30
+ MedianTestOut,
31
+ OutputTable,
32
+ PointBFOut,
33
+ PowerCurveOut,
34
+ SignTestOut,
35
+ SimDataOut,
36
+ TPowerOut,
37
+ WilcoxonLargeOut,
38
+ WilcoxonSmallOut,
39
+ results_to_dataframe,
40
+ )
41
+ from .sign_test import dfba_sign_test
42
+ from .simulation import (
43
+ dfba_bayes_vs_t_power,
44
+ dfba_power_curve,
45
+ dfba_sim_data,
46
+ )
47
+ from .wilcoxon import dfba_wilcoxon
48
+
49
+ __version__ = "0.1.0"
50
+
51
+ __all__ = [ # noqa: RUF022 - grouped by kind for readability
52
+ "__version__",
53
+ # functions
54
+ "dfba_beta_bayes_factor",
55
+ "dfba_beta_contrast",
56
+ "dfba_beta_descriptive",
57
+ "dfba_binomial",
58
+ "dfba_bivariate_concordance",
59
+ "dfba_gamma",
60
+ "dfba_mann_whitney",
61
+ "dfba_mcnemar",
62
+ "dfba_median_test",
63
+ "dfba_sign_test",
64
+ "dfba_wilcoxon",
65
+ # Phase 2 simulation
66
+ "dfba_sim_data",
67
+ "dfba_power_curve",
68
+ "dfba_bayes_vs_t_power",
69
+ # result objects
70
+ "DFBAResult",
71
+ "BetaContrastOut",
72
+ "BetaDescriptiveOut",
73
+ "BetaOutputDF",
74
+ "BinomialOut",
75
+ "BivariateConcordanceOut",
76
+ "BivariateConcordanceStarOut",
77
+ "GammaOut",
78
+ "IntervalBFOut",
79
+ "MannWhitneyLargeOut",
80
+ "MannWhitneySmallOut",
81
+ "McnemarOut",
82
+ "MedianTestOut",
83
+ "OutputTable",
84
+ "PointBFOut",
85
+ "PowerCurveOut",
86
+ "SignTestOut",
87
+ "SimDataOut",
88
+ "TPowerOut",
89
+ "WilcoxonLargeOut",
90
+ "WilcoxonSmallOut",
91
+ # batch helpers
92
+ "results_to_dataframe",
93
+ ]
dfbayes/_core.py ADDED
@@ -0,0 +1,208 @@
1
+ """Shared numerical and validation helpers.
2
+
3
+ The port deliberately mirrors R's scalar behaviour, including division by zero
4
+ (``Inf``/``-Inf``/``NaN``) and the fact that ``is.na(NaN)`` is TRUE.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ from scipy.special import betainc, betaincinv, betaln
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # NA / NaN
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def is_na(value: Any) -> bool:
20
+ """R's ``is.na``: TRUE for both NA (None) and NaN."""
21
+ if value is None:
22
+ return True
23
+ try:
24
+ return bool(np.isnan(value))
25
+ except (TypeError, ValueError):
26
+ return False
27
+
28
+
29
+ def as_float_vector(values: Any, name: str = "values") -> np.ndarray:
30
+ """Convert a sequence to a 1-D float array, mapping None to NaN.
31
+
32
+ This is the input-side equivalent of R's numeric vector coercion; ``None``
33
+ and ``NaN`` both become ``NaN`` and are treated as missing.
34
+ """
35
+ if values is None:
36
+ arr = np.array([np.nan], dtype=float)
37
+ elif isinstance(values, np.ndarray):
38
+ arr = values.astype(float, copy=False)
39
+ if arr.ndim == 0:
40
+ arr = arr.reshape(1)
41
+ else:
42
+ try:
43
+ arr = np.asarray(list(values), dtype=float)
44
+ except TypeError:
45
+ # R treats a scalar as a length-1 vector.
46
+ arr = np.array([float(values)], dtype=float)
47
+ if arr.ndim != 1:
48
+ raise ValueError(f"{name} must be a one-dimensional vector")
49
+ return arr
50
+
51
+
52
+ def as_scalar(value: Any, name: str) -> float:
53
+ """Coerce a scalar argument to float, mapping None to NaN."""
54
+ if value is None:
55
+ return float("nan")
56
+ try:
57
+ return float(value)
58
+ except (TypeError, ValueError) as exc:
59
+ raise ValueError(f"{name} must be a single numeric value") from exc
60
+
61
+
62
+ def rdiv(numerator: float, denominator: float) -> float:
63
+ """Division with R's semantics: 0/0 -> NaN, x/0 -> +/-Inf."""
64
+ num = float(numerator)
65
+ den = float(denominator)
66
+ if math.isnan(num) or math.isnan(den):
67
+ return float("nan")
68
+ if den == 0.0:
69
+ if num == 0.0:
70
+ return float("nan")
71
+ return math.copysign(math.inf, num)
72
+ return num / den
73
+
74
+
75
+ def r_pow(x: float, y: float) -> float:
76
+ """R's ``^`` for the cases used upstream.
77
+
78
+ R evaluates a non-negative integer power by repeated multiplication
79
+ (``x^4`` is ``((x*x)*x)*x``), not libm ``pow``. The two differ by one ulp
80
+ often enough to matter: the Mann-Whitney polynomial subtracts terms of
81
+ order 1e3 to obtain a result of order 1e-2, so a one-ulp change in ``x^4``
82
+ is amplified to ~1e-12 in the output.
83
+ """
84
+ xf = float(x)
85
+ yf = float(y)
86
+ if yf >= 0.0 and yf.is_integer():
87
+ result = 1.0
88
+ for _ in range(int(yf)):
89
+ result *= xf
90
+ return result
91
+ return xf ** yf
92
+
93
+
94
+ def rseq(start: float, stop: float, by: float, *, n: int | None = None) -> np.ndarray:
95
+ """Approximate R's ``seq`` for the fixed grids used upstream.
96
+
97
+ Upstream only uses ``seq`` with a known number of points (201, 1001, 200,
98
+ 101), so callers pass ``n`` and we use ``linspace``. The endpoint is
99
+ included, matching R.
100
+ """
101
+ if n is None:
102
+ raise TypeError("rseq requires n")
103
+ return np.linspace(start, stop, n)
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Beta distribution functions (R's pbeta/qbeta/dbeta equivalents)
108
+ # ---------------------------------------------------------------------------
109
+
110
+ def pbeta(q: float, a: float, b: float) -> float:
111
+ if is_na(q) or is_na(a) or is_na(b):
112
+ return float("nan")
113
+ return float(betainc(a, b, q))
114
+
115
+
116
+ def pbeta_vec(q: np.ndarray, a: float, b: float) -> np.ndarray:
117
+ return betainc(a, b, np.asarray(q, dtype=float))
118
+
119
+
120
+ def qbeta(p: float, a: float, b: float) -> float:
121
+ if is_na(p) or is_na(a) or is_na(b):
122
+ return float("nan")
123
+ return float(betaincinv(a, b, p))
124
+
125
+
126
+ def qbeta_vec(p: np.ndarray, a: float, b: float) -> np.ndarray:
127
+ return betaincinv(a, b, np.asarray(p, dtype=float))
128
+
129
+
130
+ def dbeta(x: float, a: float, b: float) -> float:
131
+ """Beta density, including R's boundary conventions."""
132
+ if is_na(x) or is_na(a) or is_na(b):
133
+ return float("nan")
134
+ x = float(x)
135
+ if x < 0.0 or x > 1.0:
136
+ return 0.0
137
+ if x == 0.0:
138
+ if a < 1.0:
139
+ return math.inf
140
+ if a == 1.0:
141
+ return float(b)
142
+ return 0.0
143
+ if x == 1.0:
144
+ if b < 1.0:
145
+ return math.inf
146
+ if b == 1.0:
147
+ return float(a)
148
+ return 0.0
149
+ log_density = (a - 1.0) * math.log(x) + (b - 1.0) * math.log1p(-x) - float(betaln(a, b))
150
+ return math.exp(log_density)
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # Validation helpers
155
+ # ---------------------------------------------------------------------------
156
+
157
+ def check_positive_finite(a: Any, b: Any, message: str) -> tuple[float, float]:
158
+ av = as_scalar(a, "a")
159
+ bv = as_scalar(b, "b")
160
+ for value in (av, bv):
161
+ if math.isnan(value) or math.isinf(value) or value <= 0.0:
162
+ raise ValueError(message)
163
+ return av, bv
164
+
165
+
166
+ def check_prob_interval(value: Any, *, allow_zero_one: bool, message: str) -> float:
167
+ p = as_scalar(value, "prob_interval")
168
+ if math.isnan(p):
169
+ # R reaches `if (NA)` and raises a base error, not the custom stop().
170
+ raise ValueError("missing value where TRUE/FALSE needed")
171
+ if allow_zero_one:
172
+ if p < 0.0 or p > 1.0:
173
+ raise ValueError(message)
174
+ else:
175
+ if p <= 0.0 or p >= 1.0:
176
+ raise ValueError(message)
177
+ return p
178
+
179
+
180
+ def check_nonnegative_count(
181
+ value: Any,
182
+ name: str,
183
+ negative_message: str,
184
+ integer_message: str,
185
+ ) -> int | float:
186
+ """Validate a count in R's order: negativity/NA first, integrality second.
187
+
188
+ Preserves whether the caller supplied an int or a float, matching R's
189
+ observable ``integer`` vs ``double`` distinction for inputs.
190
+ """
191
+ if value is None:
192
+ raise ValueError(negative_message)
193
+ try:
194
+ numeric = float(value)
195
+ except (TypeError, ValueError) as exc:
196
+ raise ValueError(negative_message) from exc
197
+ if math.isnan(numeric) or numeric < 0.0:
198
+ raise ValueError(negative_message)
199
+ if numeric != round(numeric):
200
+ raise ValueError(integer_message)
201
+ if isinstance(value, (int, np.integer)):
202
+ return int(value)
203
+ return numeric
204
+
205
+
206
+ def result_fields(obj: Any) -> list[tuple[str, Any]]:
207
+ """Return (name, value) pairs in R slot order."""
208
+ return [(name, getattr(obj, name)) for name in obj.R_FIELDS]
dfbayes/beta.py ADDED
@@ -0,0 +1,209 @@
1
+ """Beta descriptive statistics and Bayes factors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from ._core import (
11
+ as_scalar,
12
+ check_positive_finite,
13
+ check_prob_interval,
14
+ dbeta,
15
+ pbeta,
16
+ qbeta,
17
+ qbeta_vec,
18
+ r_pow,
19
+ rdiv,
20
+ )
21
+ from .results import (
22
+ BetaDescriptiveOut,
23
+ BetaOutputDF,
24
+ IntervalBFOut,
25
+ PointBFOut,
26
+ )
27
+
28
+
29
+ def dfba_beta_descriptive(
30
+ a: float,
31
+ b: float,
32
+ prob_interval: float = 0.95,
33
+ ) -> BetaDescriptiveOut:
34
+ """Descriptive statistics for a beta distribution.
35
+
36
+ Mirrors ``DFBA::dfba_beta_descriptive``. An undefined mode or HDI is
37
+ returned as ``None`` (R stores a logical ``NA`` there).
38
+ """
39
+ pi = check_prob_interval(
40
+ prob_interval,
41
+ allow_zero_one=True,
42
+ message="The probability for the interval estimate must be between 0 and 1.",
43
+ )
44
+ av, bv = check_positive_finite(
45
+ a, b,
46
+ "Both the a and b shape parameters for a beta must be positive and finite",
47
+ )
48
+
49
+ phimean = av / (av + bv)
50
+ phivariance = (av * bv) / (r_pow(av + bv, 2) * (av + bv + 1))
51
+ phimedian = qbeta(0.5, av, bv)
52
+
53
+ undefined_mode = (av == 1.0 and bv == 1.0) or av < 1.0 or bv < 1.0
54
+ phimode = None if undefined_mode else (av - 1.0) / (av + bv - 2.0)
55
+
56
+ qlequal = qbeta((1.0 - pi) / 2.0, av, bv)
57
+ qhequal = qbeta(pi + ((1.0 - pi) / 2.0), av, bv)
58
+
59
+ if undefined_mode:
60
+ qlmin = None
61
+ qhmax = None
62
+ else:
63
+ # R: seq(0, 1 - prob_interval, (1 - prob_interval)/1000)
64
+ alpha_l = np.linspace(0.0, 1.0 - pi, 1001)
65
+ ql = qbeta_vec(alpha_l, av, bv)
66
+ qh = qbeta_vec(pi + alpha_l, av, bv)
67
+ diff = qh - ql
68
+ index = int(np.argmin(diff)) # first index attaining the minimum
69
+ qlmin = float(ql[index])
70
+ qhmax = float(qh[index])
71
+
72
+ # R: seq(0, 1, .005) -> 201 points
73
+ x = np.arange(201, dtype=float) * 0.005
74
+ density = np.array([dbeta(float(xi), av, bv) for xi in x])
75
+ cumulative = np.array(
76
+ [pbeta(float(xi), av, bv) for xi in x]
77
+ )
78
+ outputdf = BetaOutputDF(x=x, density=density, cumulative_prob=cumulative)
79
+
80
+ return BetaDescriptiveOut(
81
+ a=av,
82
+ b=bv,
83
+ prob_interval=pi,
84
+ x_mean=phimean,
85
+ x_median=phimedian,
86
+ x_mode=phimode,
87
+ x_variance=phivariance,
88
+ eti_lower=qlequal,
89
+ eti_upper=qhequal,
90
+ hdi_lower=qlmin,
91
+ hdi_upper=qhmax,
92
+ outputdf=outputdf,
93
+ )
94
+
95
+
96
+ def dfba_beta_bayes_factor(
97
+ a_post: float,
98
+ b_post: float,
99
+ method: str,
100
+ H0: Any,
101
+ a0: float = 1.0,
102
+ b0: float = 1.0,
103
+ ) -> PointBFOut | IntervalBFOut:
104
+ """Bayes factor for a point or interval null hypothesis."""
105
+ if method != "point" and method != "interval":
106
+ raise ValueError("method must be either 'point' or 'interval'")
107
+
108
+ a0v, b0v = check_positive_finite(
109
+ a0, b0, "Both a0 and b0 must be positive and finite."
110
+ )
111
+ a_postv = as_scalar(a_post, "a_post")
112
+ b_postv = as_scalar(b_post, "b_post")
113
+
114
+ if (
115
+ a_postv < a0v
116
+ or b_postv < b0v
117
+ or math.isnan(a_postv)
118
+ or math.isnan(b_postv)
119
+ ):
120
+ raise ValueError(
121
+ "Both a_post and b_post cannot be less than the respective a0 and b0 values"
122
+ )
123
+
124
+ if method == "point":
125
+ # R checks length(H0), so a length-1 vector is accepted as well as a
126
+ # scalar.
127
+ if np.ndim(H0) == 1 and len(H0) == 1:
128
+ H0 = H0[0]
129
+ elif np.ndim(H0) != 0:
130
+ raise ValueError("'H0' must be a single numeric value when method = 'point'")
131
+ if H0 is None:
132
+ raise ValueError("missing value where TRUE/FALSE needed")
133
+ h0 = as_scalar(H0, "H0")
134
+ if math.isnan(h0):
135
+ raise ValueError("missing value where TRUE/FALSE needed")
136
+ if h0 > 1.0 or h0 < 0.0:
137
+ raise ValueError(
138
+ "H0 must be greater than or equal to 0 and less than or equal to 1"
139
+ )
140
+
141
+ dprior = dbeta(h0, a0v, b0v)
142
+ dpost = dbeta(h0, a_postv, b_postv)
143
+ bf10 = math.inf if dpost == 0.0 else rdiv(dprior, dpost)
144
+ bf01 = math.inf if dprior == 0.0 else rdiv(dpost, dprior)
145
+
146
+ return PointBFOut(
147
+ method=method,
148
+ a_post=a_postv,
149
+ b_post=b_postv,
150
+ a0=a0v,
151
+ b0=b0v,
152
+ BF10=bf10,
153
+ BF01=bf01,
154
+ null_hypothesis=h0,
155
+ dpriorH0=dprior,
156
+ dpostH0=dpost,
157
+ )
158
+
159
+ # interval
160
+ if np.ndim(H0) != 1 or len(H0) != 2:
161
+ raise ValueError(
162
+ "'H0' must be a vector of two values (lower and upper) when method = 'interval'"
163
+ )
164
+ if any(v is None for v in H0):
165
+ raise ValueError("missing value where TRUE/FALSE needed")
166
+ lower = as_scalar(H0[0], "H0[0]")
167
+ upper = as_scalar(H0[1], "H0[1]")
168
+ if math.isnan(lower) or math.isnan(upper):
169
+ raise ValueError("missing value where TRUE/FALSE needed")
170
+ if any(v > 1.0 or v < 0.0 for v in (lower, upper)):
171
+ raise ValueError(
172
+ "H0 interval limits must be greater than or equal to 0 and less than or equal to 1"
173
+ )
174
+ if lower >= upper:
175
+ raise ValueError(
176
+ "When method = 'interval', H0 upper limit must be greater than H0 lower limit"
177
+ )
178
+
179
+ ph0 = pbeta(upper, a0v, b0v) - pbeta(lower, a0v, b0v)
180
+ ph1 = 1.0 - ph0
181
+ post_h0 = pbeta(upper, a_postv, b_postv) - pbeta(lower, a_postv, b_postv)
182
+ post_h1 = 1.0 - post_h0
183
+
184
+ bf10 = (
185
+ math.inf
186
+ if post_h0 == 0.0
187
+ else rdiv(rdiv(1.0, post_h0) - 1.0, rdiv(1.0, ph0) - 1.0)
188
+ )
189
+ bf01 = (
190
+ math.inf
191
+ if post_h1 == 0.0
192
+ else rdiv(rdiv(1.0, ph0) - 1.0, rdiv(1.0, post_h0) - 1.0)
193
+ )
194
+
195
+ return IntervalBFOut(
196
+ method=method,
197
+ a_post=a_postv,
198
+ b_post=b_postv,
199
+ a0=a0v,
200
+ b0=b0v,
201
+ BF10=bf10,
202
+ BF01=bf01,
203
+ H0lower=lower,
204
+ H0upper=upper,
205
+ pH0=ph0,
206
+ pH1=ph1,
207
+ postH0=post_h0,
208
+ postH1=post_h1,
209
+ )
dfbayes/binomial.py ADDED
@@ -0,0 +1,57 @@
1
+ """Bayesian binomial rate inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._core import check_nonnegative_count, check_positive_finite, check_prob_interval
6
+ from .beta import dfba_beta_descriptive
7
+ from .results import BinomialOut
8
+
9
+
10
+ def dfba_binomial(
11
+ n1: float,
12
+ n2: float,
13
+ a0: float = 1.0,
14
+ b0: float = 1.0,
15
+ prob_interval: float = 0.95,
16
+ ) -> BinomialOut:
17
+ """Bayesian inference for a binomial rate parameter.
18
+
19
+ ``n1`` and ``n2`` must be non-negative integers; ``a0``/``b0`` must be
20
+ positive and finite. NA/NaN anywhere raises ``ValueError``, matching R.
21
+ """
22
+ a0v, b0v = check_positive_finite(
23
+ a0, b0, "Both a0 and b0 must be positive and finite"
24
+ )
25
+ pi = check_prob_interval(
26
+ prob_interval,
27
+ allow_zero_one=False,
28
+ message="prob_interval must be greater than 0 and less than 1",
29
+ )
30
+ n1v = check_nonnegative_count(
31
+ n1, "n1", "Neither n1 nor n2 can be negative", "n1 and n2 must be integers"
32
+ )
33
+ n2v = check_nonnegative_count(
34
+ n2, "n2", "Neither n1 nor n2 can be negative", "n1 and n2 must be integers"
35
+ )
36
+
37
+ a_post = n1v + a0v
38
+ b_post = n2v + b0v
39
+
40
+ descriptive = dfba_beta_descriptive(a_post, b_post, prob_interval=pi)
41
+
42
+ return BinomialOut(
43
+ n1=n1v,
44
+ n2=n2v,
45
+ a0=a0v,
46
+ b0=b0v,
47
+ prob_interval=pi,
48
+ a_post=a_post,
49
+ b_post=b_post,
50
+ phimean=descriptive.x_mean,
51
+ phimedian=descriptive.x_median,
52
+ phimode=descriptive.x_mode,
53
+ eti_lower=descriptive.eti_lower,
54
+ eti_upper=descriptive.eti_upper,
55
+ hdi_lower=descriptive.hdi_lower,
56
+ hdi_upper=descriptive.hdi_upper,
57
+ )