SSTT-visuals 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.
- SSTT_visuals/__init__.py +5 -0
- SSTT_visuals/clusterStats.py +298 -0
- SSTT_visuals/mos.py +293 -0
- SSTT_visuals/plot.py +283 -0
- SSTT_visuals/primeslist.py +705 -0
- SSTT_visuals/wordDB.json +121070 -0
- SSTT_visuals/wordGroupTest.json +44 -0
- sstt_visuals-0.1.0.dist-info/METADATA +47 -0
- sstt_visuals-0.1.0.dist-info/RECORD +12 -0
- sstt_visuals-0.1.0.dist-info/WHEEL +5 -0
- sstt_visuals-0.1.0.dist-info/licenses/LICENSE +26 -0
- sstt_visuals-0.1.0.dist-info/top_level.txt +1 -0
SSTT_visuals/__init__.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Two goodness-of-fit tests against the null hypothesis that a 5x5 array of
|
|
3
|
+
nonnegative-integer counts arises from N i.i.d. uniform draws over the 25
|
|
4
|
+
cells (equivalently: the count vector is Multinomial(N, p=1/25 each)).
|
|
5
|
+
|
|
6
|
+
1. occupancy_test: T = number of empty cells. Reports P(T >= t_obs), exact.
|
|
7
|
+
2. scan_statistic_test: Q = min over "2-by-n" rectangles of the rectangle's
|
|
8
|
+
own upper-tail binomial p-value. Reports P(Q <= q_obs) via simulation,
|
|
9
|
+
since the exact null distribution of a minimum over overlapping,
|
|
10
|
+
dependent windows has no tractable closed form.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from fractions import Fraction
|
|
14
|
+
from math import comb
|
|
15
|
+
|
|
16
|
+
import scipy.stats as stats
|
|
17
|
+
|
|
18
|
+
from .mos import normal_isf_asymptotic
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from scipy.stats import binom
|
|
22
|
+
|
|
23
|
+
GRID_SIZE = 5
|
|
24
|
+
CENTER = (GRID_SIZE // 2, GRID_SIZE // 2) # (2, 2), zero-indexed
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Test 1: Occupancy test
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def _surjections(n: int, j: int) -> int:
|
|
32
|
+
"""Number of surjective functions from an n-set onto a j-set, via
|
|
33
|
+
inclusion-exclusion. Surj(n, 0) = 1 iff n == 0 (handled naturally by the
|
|
34
|
+
formula since 0**0 = 1 in Python)."""
|
|
35
|
+
total = 0
|
|
36
|
+
for i in range(j + 1):
|
|
37
|
+
total += (-1) ** i * comb(j, i) * (j - i) ** n
|
|
38
|
+
return total
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def occupancyStatisticTest(data: np.ndarray):
|
|
42
|
+
"""
|
|
43
|
+
Exact p-value P(T >= t_obs) for the occupancy test, where T is the
|
|
44
|
+
number of empty cells among the m = 25 cells of `data`, under the null
|
|
45
|
+
that N = sum(data) observations are i.i.d. uniform over the 25 cells.
|
|
46
|
+
|
|
47
|
+
Returns the p-value (float).
|
|
48
|
+
"""
|
|
49
|
+
data = np.asarray(data)
|
|
50
|
+
if data.shape != (GRID_SIZE, GRID_SIZE):
|
|
51
|
+
raise ValueError(f"expected a {GRID_SIZE}x{GRID_SIZE} array")
|
|
52
|
+
|
|
53
|
+
m = data.size # 25 boxes
|
|
54
|
+
n = int(data.sum()) # total balls
|
|
55
|
+
t_obs = int(np.sum(data == 0))
|
|
56
|
+
|
|
57
|
+
p_tail = Fraction(0)
|
|
58
|
+
for k in range(t_obs, m + 1):
|
|
59
|
+
j = m - k # number of "occupied" boxes required among the chosen k...
|
|
60
|
+
# careful: k here is the number of EMPTY boxes, so m-k boxes must
|
|
61
|
+
# all be nonempty -> surjection of n balls onto (m-k) boxes
|
|
62
|
+
occupied = m - k
|
|
63
|
+
surj = _surjections(n, occupied)
|
|
64
|
+
p_tail += Fraction(comb(m, k) * surj, m ** n)
|
|
65
|
+
|
|
66
|
+
p_value = float(p_tail)
|
|
67
|
+
if p_value <= 0:
|
|
68
|
+
z_score = normal_isf_asymptotic(p_tail)
|
|
69
|
+
lp = None
|
|
70
|
+
else:
|
|
71
|
+
z_score = stats.norm.isf(p_value)
|
|
72
|
+
lp = -np.log(p_value)
|
|
73
|
+
return (p_value, z_score, lp)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Test 2: Scan statistic test
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def _enumerate_rectangles(size: int = GRID_SIZE, center=CENTER):
|
|
81
|
+
"""
|
|
82
|
+
All axis-aligned rectangles with one side exactly length 2 and the other
|
|
83
|
+
side length 2..size (both orientations: 2-tall / n-wide, and n-tall /
|
|
84
|
+
2-wide, with n >= 2 so both dimensions are at least 2), excluding any
|
|
85
|
+
rectangle that contains `center`.
|
|
86
|
+
|
|
87
|
+
Returns a list of (r0, r1, c0, c1, flat_indices, p) tuples where
|
|
88
|
+
(r0..r1, c0..c1) are inclusive cell ranges, flat_indices is the list of
|
|
89
|
+
flattened (row-major) cell indices in the rectangle, and p = ncells/25
|
|
90
|
+
is the null probability mass covered by the rectangle.
|
|
91
|
+
"""
|
|
92
|
+
rects = set()
|
|
93
|
+
|
|
94
|
+
for w in range(2, size + 1):
|
|
95
|
+
h = 2
|
|
96
|
+
for r0 in range(size - h + 1):
|
|
97
|
+
for c0 in range(size - w + 1):
|
|
98
|
+
r1, c1 = r0 + h - 1, c0 + w - 1
|
|
99
|
+
if r0 <= center[0] <= r1 and c0 <= center[1] <= c1:
|
|
100
|
+
continue
|
|
101
|
+
rects.add((r0, r1, c0, c1))
|
|
102
|
+
|
|
103
|
+
for h in range(2, size + 1):
|
|
104
|
+
w = 2
|
|
105
|
+
for r0 in range(size - h + 1):
|
|
106
|
+
for c0 in range(size - w + 1):
|
|
107
|
+
r1, c1 = r0 + h - 1, c0 + w - 1
|
|
108
|
+
if r0 <= center[0] <= r1 and c0 <= center[1] <= c1:
|
|
109
|
+
continue
|
|
110
|
+
rects.add((r0, r1, c0, c1))
|
|
111
|
+
|
|
112
|
+
rect_info = []
|
|
113
|
+
for (r0, r1, c0, c1) in sorted(rects):
|
|
114
|
+
idx = [r * size + c for r in range(r0, r1 + 1) for c in range(c0, c1 + 1)]
|
|
115
|
+
p = len(idx) / (size * size)
|
|
116
|
+
rect_info.append((r0, r1, c0, c1, idx, p))
|
|
117
|
+
return rect_info
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# These are the two composite regions in grid coordinates. Each tuple keeps
|
|
121
|
+
# the normal rectangle fields, followed by the cells in the union and the
|
|
122
|
+
# component rectangles used by the plotter for highlighting.
|
|
123
|
+
_SPECIAL_COMPOSITE_RECTS = [
|
|
124
|
+
(
|
|
125
|
+
0, 4, 0, 2,
|
|
126
|
+
[0, 1, 5, 6, 10, 11, 15, 16, 17, 20, 21, 22],
|
|
127
|
+
12 / 25,
|
|
128
|
+
((3, 4, 0, 2), (0, 2, 0, 1)),
|
|
129
|
+
),
|
|
130
|
+
(
|
|
131
|
+
0, 4, 2, 4,
|
|
132
|
+
[2, 3, 4, 7, 8, 9, 13, 14, 18, 19, 23, 24],
|
|
133
|
+
12 / 25,
|
|
134
|
+
((0, 1, 2, 4), (2, 4, 3, 4)),
|
|
135
|
+
),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_RECTS = _enumerate_rectangles() + _SPECIAL_COMPOSITE_RECTS
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _compute_Q(flat_counts: np.ndarray, n_total: int) -> float:
|
|
143
|
+
"""flat_counts: 1D array of length 25 (row-major). Returns Q = min_r q_r."""
|
|
144
|
+
q_min = 1.0
|
|
145
|
+
min_rect = None
|
|
146
|
+
for rect in _RECTS:
|
|
147
|
+
idx = rect[4]
|
|
148
|
+
p = rect[5]
|
|
149
|
+
s = flat_counts[idx].sum()
|
|
150
|
+
q_r = binom.sf(s - 1, n_total, p) # P(X >= s)
|
|
151
|
+
if q_r < q_min:
|
|
152
|
+
q_min = q_r
|
|
153
|
+
min_rect = rect
|
|
154
|
+
return q_min, min_rect
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _compute_Q_batch(flat_counts_batch: np.ndarray, n_total: int) -> np.ndarray:
|
|
158
|
+
"""Vectorised version: flat_counts_batch has shape (n_sims, 25)."""
|
|
159
|
+
n_sims = flat_counts_batch.shape[0]
|
|
160
|
+
q_min = np.ones(n_sims)
|
|
161
|
+
for rect in _RECTS:
|
|
162
|
+
idx = rect[4]
|
|
163
|
+
p = rect[5]
|
|
164
|
+
s = flat_counts_batch[:, idx].sum(axis=1)
|
|
165
|
+
q_r = binom.sf(s - 1, n_total, p)
|
|
166
|
+
np.minimum(q_min, q_r, out=q_min)
|
|
167
|
+
return q_min
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def scanStatisticTest(data, n_sims: int = 2000000, random_state=None, return_rect: bool = False):
|
|
171
|
+
"""
|
|
172
|
+
Monte Carlo p-value P(Q <= q_obs) for the scan statistic test.
|
|
173
|
+
|
|
174
|
+
Q = min over all valid "2-by-n" rectangles r of q_r, where q_r is the
|
|
175
|
+
upper-tail binomial probability P(count in r >= observed count in r)
|
|
176
|
+
under the null that N = sum(data) observations are i.i.d. uniform over
|
|
177
|
+
the 25 cells.
|
|
178
|
+
|
|
179
|
+
Returns the p-value, z-score, log-p-value, q_obs, and optionally the
|
|
180
|
+
rectangle with the minimum q-value.
|
|
181
|
+
"""
|
|
182
|
+
data = np.asarray(data)
|
|
183
|
+
if data.shape != (GRID_SIZE, GRID_SIZE):
|
|
184
|
+
raise ValueError(f"expected a {GRID_SIZE}x{GRID_SIZE} array")
|
|
185
|
+
|
|
186
|
+
n_total = int(data.sum())
|
|
187
|
+
flat_obs = data.reshape(-1)
|
|
188
|
+
q_obs, min_rect = _compute_Q(flat_obs, n_total)
|
|
189
|
+
|
|
190
|
+
if n_total == 0:
|
|
191
|
+
# Every rectangle sum is trivially 0 >= 0, so q_r = 1 for all r,
|
|
192
|
+
# Q = 1 always, under the null too -> degenerate p-value of 1.
|
|
193
|
+
result = (q_obs, 1.0)
|
|
194
|
+
if return_rect:
|
|
195
|
+
return (1.0, 0.0, 0.0, q_obs, min_rect)
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
rng = np.random.default_rng(random_state)
|
|
199
|
+
flat_p = np.full(GRID_SIZE * GRID_SIZE, 1.0 / (GRID_SIZE * GRID_SIZE))
|
|
200
|
+
sims = rng.multinomial(n_total, flat_p, size=n_sims) # shape (n_sims, 25)
|
|
201
|
+
|
|
202
|
+
q_sims = _compute_Q_batch(sims, n_total)
|
|
203
|
+
p_value = float(np.mean(q_sims <= q_obs))
|
|
204
|
+
|
|
205
|
+
z_score = stats.norm.isf(p_value)
|
|
206
|
+
lp = np.inf if p_value == 0 else -np.log(p_value)
|
|
207
|
+
|
|
208
|
+
if return_rect:
|
|
209
|
+
return (p_value, z_score, lp, q_obs, min_rect)
|
|
210
|
+
return (p_value, z_score, lp, q_obs)
|
|
211
|
+
|
|
212
|
+
def _min_tail_prob_at_least_as_extreme(n_total: int, p: float, q_threshold: float) -> float:
|
|
213
|
+
"""
|
|
214
|
+
For S ~ Binomial(n_total, p), let q(s) = P(S >= s) be the upper-tail
|
|
215
|
+
p-value function (non-increasing, right-continuous step function of s).
|
|
216
|
+
Returns P(q(S) <= q_threshold), exactly, with no simulation: this is
|
|
217
|
+
just sf(s* - 1) where s* is the smallest integer count whose own tail
|
|
218
|
+
probability already drops to or below q_threshold.
|
|
219
|
+
"""
|
|
220
|
+
s_values = np.arange(0, n_total + 1)
|
|
221
|
+
tail = binom.sf(s_values - 1, n_total, p) # monotone non-increasing in s
|
|
222
|
+
mask = tail <= q_threshold
|
|
223
|
+
if not mask.any():
|
|
224
|
+
return 0.0
|
|
225
|
+
s_star = int(s_values[mask][0])
|
|
226
|
+
return float(binom.sf(s_star - 1, n_total, p))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def scanStatisticTestBonferroni(data: np.ndarray, return_rect: bool = False):
|
|
230
|
+
"""
|
|
231
|
+
Exact (analytic, no simulation) UPPER BOUND on P(Q <= q_obs), via the
|
|
232
|
+
union bound over the fixed set of 38 rectangles:
|
|
233
|
+
|
|
234
|
+
P(Q <= q) = P(exists r: q_r <= q) <= sum_r P(q_r <= q)
|
|
235
|
+
|
|
236
|
+
Unlike the Monte Carlo version, this has no floor on how small a
|
|
237
|
+
p-value it can report -- useful when the true p-value is far smaller
|
|
238
|
+
than 1/n_sims. It is conservative: the true p-value is <= this bound.
|
|
239
|
+
|
|
240
|
+
Returns (bound, z_score, lp, q_obs) by default, or includes the
|
|
241
|
+
rectangle with the minimum q-value when return_rect=True.
|
|
242
|
+
"""
|
|
243
|
+
data = np.asarray(data)
|
|
244
|
+
if data.shape != (GRID_SIZE, GRID_SIZE):
|
|
245
|
+
raise ValueError(f"expected a {GRID_SIZE}x{GRID_SIZE} array")
|
|
246
|
+
|
|
247
|
+
n_total = int(data.sum())
|
|
248
|
+
flat_obs = data.reshape(-1)
|
|
249
|
+
q_obs, min_rect = _compute_Q(flat_obs, n_total)
|
|
250
|
+
|
|
251
|
+
if n_total == 0:
|
|
252
|
+
raise ValueError("cannot compute scan statistic for empty data (n_total=0)")
|
|
253
|
+
|
|
254
|
+
bound = 0.0
|
|
255
|
+
for rect in _RECTS:
|
|
256
|
+
p = rect[5]
|
|
257
|
+
bound += _min_tail_prob_at_least_as_extreme(n_total, p, q_obs)
|
|
258
|
+
bound = min(bound, 1.0) # a union bound can nominally exceed 1
|
|
259
|
+
|
|
260
|
+
z_score = stats.norm.isf(bound)
|
|
261
|
+
lp = np.inf if bound == 0 else -np.log(bound)
|
|
262
|
+
if return_rect:
|
|
263
|
+
return (bound, z_score, lp, q_obs, min_rect)
|
|
264
|
+
return (bound, z_score, lp, q_obs)
|
|
265
|
+
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
# Demo
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
# if __name__ == "__main__":
|
|
271
|
+
# rng = np.random.default_rng(0)
|
|
272
|
+
|
|
273
|
+
# # Example: 40 observations thrown uniformly at random (should look "null-ish")
|
|
274
|
+
# example = rng.multinomial(40, np.full(25, 1 / 25)).reshape(5, 5)
|
|
275
|
+
# print("Example data:\n", example)
|
|
276
|
+
|
|
277
|
+
# t_obs = int(np.sum(example == 0))
|
|
278
|
+
# p_occ = occupancyStatisticTest(example)
|
|
279
|
+
# print(f"\nOccupancy test: t_obs = {t_obs}, P(T >= t_obs) = {p_occ:.4f}")
|
|
280
|
+
|
|
281
|
+
# q_obs, p_scan = scanStatisticTest(example, n_sims=2000000, random_state=1)
|
|
282
|
+
# print(f"Scan statistic test: q_obs = {q_obs:.4f}, P(Q <= q_obs) = {p_scan:.4f}")
|
|
283
|
+
|
|
284
|
+
# # Example designed to trip the tests: a clustered/skewed pattern
|
|
285
|
+
# skewed = np.array([
|
|
286
|
+
# [10, 10, 0, 0, 0],
|
|
287
|
+
# [10, 10, 0, 0, 0],
|
|
288
|
+
# [0, 0, 0, 0, 0],
|
|
289
|
+
# [0, 0, 0, 0, 0],
|
|
290
|
+
# [0, 0, 0, 0, 0],
|
|
291
|
+
# ])
|
|
292
|
+
# print("\nSkewed data:\n", skewed)
|
|
293
|
+
# t_obs2 = int(np.sum(skewed == 0))
|
|
294
|
+
# p_occ2 = occupancy_test(skewed)
|
|
295
|
+
# print(f"\nOccupancy test: t_obs = {t_obs2}, P(T >= t_obs) = {p_occ2:.6f}")
|
|
296
|
+
|
|
297
|
+
# q_obs2, p_scan2 = scan_statistic_test(skewed, n_sims=20000, random_state=1)
|
|
298
|
+
# print(f"Scan statistic test: q_obs = {q_obs2:.6f}, P(Q <= q_obs) = {p_scan2:.6f}")
|
SSTT_visuals/mos.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import scipy.stats as stats
|
|
2
|
+
import gmpy2
|
|
3
|
+
from math import log, factorial, perm, sqrt, pi
|
|
4
|
+
from typing import Self, overload
|
|
5
|
+
from functools import cache
|
|
6
|
+
from sys import getsizeof
|
|
7
|
+
from time import perf_counter
|
|
8
|
+
try:
|
|
9
|
+
from .primeslist import PRIMES
|
|
10
|
+
except ImportError: # pragma: no cover - compatibility fallback for direct script execution
|
|
11
|
+
from primeslist import PRIMES
|
|
12
|
+
|
|
13
|
+
class PDFrac():
|
|
14
|
+
__slots__ = ('numerator', 'denominator', 'dmax', 'q')
|
|
15
|
+
|
|
16
|
+
@overload
|
|
17
|
+
def __init__(self, numerator : int): pass
|
|
18
|
+
@overload
|
|
19
|
+
def __init__(self, numerator : int, denominator : int): pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def __init__(self, numerator, denominator = 1, dmax = None, q = 1):
|
|
23
|
+
# if not (type(numerator) is int\
|
|
24
|
+
# or type(numerator) is gmpy2.mpz)\
|
|
25
|
+
# or not (type(denominator) is int\
|
|
26
|
+
# or type(denominator) is gmpy2.mpz):
|
|
27
|
+
# raise TypeError(f"PDFrac recieved {type(numerator)}, {type(denominator)}")
|
|
28
|
+
|
|
29
|
+
# dmax represents the largest witnessed multiple of the denominator. This is
|
|
30
|
+
# a simple upper bound for the largest prime that could divide the denominator.
|
|
31
|
+
|
|
32
|
+
self.q = q
|
|
33
|
+
self.dmax = dmax if dmax is not None else denominator
|
|
34
|
+
self.numerator = gmpy2.mpz(numerator)
|
|
35
|
+
self.denominator = gmpy2.mpz(denominator)
|
|
36
|
+
|
|
37
|
+
def __add__(self, frac : Self|int):
|
|
38
|
+
if type(self) != PDFrac \
|
|
39
|
+
or type(frac) not in (PDFrac, int):
|
|
40
|
+
err_str = f"Addition defined only between PDFrac and PDFrac|int, "\
|
|
41
|
+
+ f"but received {type(self)}, {type(frac)}"
|
|
42
|
+
raise TypeError(err_str)
|
|
43
|
+
|
|
44
|
+
if type(frac) == int:
|
|
45
|
+
frac = PDFrac(frac)
|
|
46
|
+
|
|
47
|
+
if self.numerator == 0:
|
|
48
|
+
return frac.copy()
|
|
49
|
+
if frac.numerator == 0:
|
|
50
|
+
return self.copy()
|
|
51
|
+
|
|
52
|
+
res = self.copy()
|
|
53
|
+
|
|
54
|
+
res.numerator = res.numerator * frac.denominator + frac.numerator * res.denominator
|
|
55
|
+
res.denominator = res.denominator * frac.denominator
|
|
56
|
+
res.dmax = max(self.denominator, frac.denominator)
|
|
57
|
+
res.q = max(self.q, frac.q)
|
|
58
|
+
return res
|
|
59
|
+
|
|
60
|
+
def __mul__(self, frac : Self|int):
|
|
61
|
+
if type(self) != PDFrac \
|
|
62
|
+
or type(frac) not in (PDFrac, int):
|
|
63
|
+
err_str = f"Multiplication defined only between PDFrac and PDFrac|int, "\
|
|
64
|
+
+ f"but received {type(self)}, {type(frac)}"
|
|
65
|
+
raise TypeError(err_str)
|
|
66
|
+
|
|
67
|
+
if type(frac) == int:
|
|
68
|
+
frac = PDFrac(frac)
|
|
69
|
+
|
|
70
|
+
if self.numerator == 0 or frac.numerator == 0:
|
|
71
|
+
return PDFrac(0)
|
|
72
|
+
|
|
73
|
+
if self.numerator == 1 and self.denominator == 1:
|
|
74
|
+
return frac.copy()
|
|
75
|
+
if frac.numerator == 1 and frac.denominator == 1:
|
|
76
|
+
return self.copy()
|
|
77
|
+
|
|
78
|
+
res = self.copy()
|
|
79
|
+
|
|
80
|
+
res.numerator *= frac.numerator
|
|
81
|
+
res.denominator *= frac.denominator
|
|
82
|
+
res.dmax = max(self.denominator, frac.denominator)
|
|
83
|
+
res.q = self.q + frac.q
|
|
84
|
+
return res
|
|
85
|
+
|
|
86
|
+
def simplify(self):
|
|
87
|
+
largest_undivided_prime = 1
|
|
88
|
+
for i in range(len(PRIMES)):
|
|
89
|
+
p = PRIMES[i]
|
|
90
|
+
if p > self.dmax or p > self.numerator:
|
|
91
|
+
break
|
|
92
|
+
|
|
93
|
+
# if (p == 2 or self.q > 2) and p <= MAXP:
|
|
94
|
+
if self.q > 4 and p <= MAXP:
|
|
95
|
+
self.pOrderBSearch(p = p, i = i)
|
|
96
|
+
if self.denominator % p == 0:
|
|
97
|
+
largest_undivided_prime = p
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
while True:
|
|
101
|
+
if self.denominator % p != 0:
|
|
102
|
+
break
|
|
103
|
+
if self.numerator % p != 0:
|
|
104
|
+
largest_undivided_prime = p
|
|
105
|
+
break
|
|
106
|
+
self.numerator //= p
|
|
107
|
+
self.denominator //= p
|
|
108
|
+
self.dmax = largest_undivided_prime
|
|
109
|
+
|
|
110
|
+
def pOrderBSearch(self, p, i):
|
|
111
|
+
if self.numerator % p != 0 or self.denominator % p != 0:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
pk = PPOW[i][-1]
|
|
115
|
+
while self.numerator % pk == 0 and self.denominator % pk == 0:
|
|
116
|
+
self.denominator //= pk
|
|
117
|
+
self.numerator //= pk
|
|
118
|
+
|
|
119
|
+
self.pobs(i)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
def pobs(self, i, d_low = None):
|
|
123
|
+
# d_low == True => (d | pk => n | pk). Hence only check d divis.
|
|
124
|
+
# d_low == False => (n | pk => d | pk). Hence only check n divis.
|
|
125
|
+
# d_low is None implies nothing. Hence check both
|
|
126
|
+
|
|
127
|
+
for pk in reversed(PPOW[i][:-1]):
|
|
128
|
+
if d_low is False:
|
|
129
|
+
d_divided = True
|
|
130
|
+
n_divided = (self.numerator % pk == 0)
|
|
131
|
+
elif d_low is True:
|
|
132
|
+
d_divided = (self.denominator % pk == 0)
|
|
133
|
+
n_divided = True
|
|
134
|
+
else:
|
|
135
|
+
d_divided = (self.denominator % pk == 0)
|
|
136
|
+
n_divided = (self.numerator % pk == 0)
|
|
137
|
+
|
|
138
|
+
if d_divided and n_divided:
|
|
139
|
+
self.denominator //= pk
|
|
140
|
+
self.numerator //= pk
|
|
141
|
+
elif d_low is None:
|
|
142
|
+
if d_divided:
|
|
143
|
+
d_low = False
|
|
144
|
+
elif n_divided:
|
|
145
|
+
d_low = True
|
|
146
|
+
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
def to_float(self) -> float:
|
|
150
|
+
return float(self.numerator / self.denominator)
|
|
151
|
+
|
|
152
|
+
def copy(self) -> Self:
|
|
153
|
+
new = PDFrac.__new__(PDFrac)
|
|
154
|
+
new.numerator = self.numerator
|
|
155
|
+
new.denominator = self.denominator
|
|
156
|
+
new.dmax = self.dmax
|
|
157
|
+
new.q = self.q
|
|
158
|
+
return new
|
|
159
|
+
|
|
160
|
+
def A(q: int, r: int, n: int, m: int) -> PDFrac:
|
|
161
|
+
q = gmpy2.mpz(q); r = gmpy2.mpz(r); n = gmpy2.mpz(n); m = gmpy2.mpz(m)
|
|
162
|
+
if q == 0:
|
|
163
|
+
return PDFrac(m**n)
|
|
164
|
+
rq = r * q
|
|
165
|
+
numerator = perm(n, rq) # n! / (n-rq)!
|
|
166
|
+
denominator = factorial(r)**q * factorial(q) # (r!)^q * q!
|
|
167
|
+
if m == q:
|
|
168
|
+
numerator *= factorial(m) # m!
|
|
169
|
+
else:
|
|
170
|
+
numerator *= perm(m, q) * (m - q)**(n - rq) # (m! / (m-q)!) * (m - q)^(n-rq)
|
|
171
|
+
return PDFrac(numerator, denominator, dmax=max(q, r), q=q)
|
|
172
|
+
|
|
173
|
+
@cache # Cache output for dynamic programming
|
|
174
|
+
def maxOrderStatistic(r : int, n : int, m : int) -> PDFrac:
|
|
175
|
+
# P(max urn <= r | marbles = n, urns = m)
|
|
176
|
+
#r : cdf
|
|
177
|
+
#n : number of balls
|
|
178
|
+
#m : number of urns
|
|
179
|
+
if r==0 and n!=0:
|
|
180
|
+
return PDFrac(0)
|
|
181
|
+
if r==0 and n==0:
|
|
182
|
+
return PDFrac(1)
|
|
183
|
+
if r >= n:
|
|
184
|
+
return PDFrac(1)
|
|
185
|
+
if n==0:
|
|
186
|
+
return PDFrac(1)
|
|
187
|
+
|
|
188
|
+
if r == 1:
|
|
189
|
+
if m >= n:
|
|
190
|
+
return PDFrac(perm(m, n), m**n)
|
|
191
|
+
else:
|
|
192
|
+
return PDFrac(0)
|
|
193
|
+
else:
|
|
194
|
+
probability = PDFrac(0)
|
|
195
|
+
qMax = n//r + 1
|
|
196
|
+
qMin = max(0, n - r*m + m)
|
|
197
|
+
for q in range(qMin, qMax):
|
|
198
|
+
probability += A(q, r, n, m) * maxOrderStatistic(r-1, n-r*q, m-q)
|
|
199
|
+
# probability.simplify()
|
|
200
|
+
|
|
201
|
+
probability *= PDFrac(1, m**n)
|
|
202
|
+
probability.simplify()
|
|
203
|
+
|
|
204
|
+
return probability
|
|
205
|
+
|
|
206
|
+
# Very small p_values <~ 1e-16 can't be captured by conjugating a float due to the scale mismatch
|
|
207
|
+
# between the p_value and 1. Hence, conjugation must be performed on the fraction before converting
|
|
208
|
+
# it to a float.
|
|
209
|
+
def conjMaxOrderStatistic(s, marbles, urns):
|
|
210
|
+
q_frac = maxOrderStatistic(s - 1, marbles, urns) # q = P(max urn < s | marbles, urns)
|
|
211
|
+
p_frac = q_frac.copy() # Cached mutable must be copied pre-edit
|
|
212
|
+
p_frac.numerator = p_frac.denominator - p_frac.numerator
|
|
213
|
+
return p_frac, q_frac
|
|
214
|
+
|
|
215
|
+
def PandZ(urnMax : int, marbles : int, urns : int, initialise = True) -> tuple[float, float, float]:
|
|
216
|
+
# urnMax = the number of marbles in the urn with the maximum number
|
|
217
|
+
# marbles = how many marbles did you place in the urns
|
|
218
|
+
# urns = the number of urns the marbles were distributed between
|
|
219
|
+
|
|
220
|
+
urnMax = int(urnMax); marbles = int(marbles); urns = int(urns)
|
|
221
|
+
|
|
222
|
+
if urnMax > 3000 or marbles > 6000:
|
|
223
|
+
raise NotImplementedError("Only supports urnMax <= P and marbles <= 2P, where P " +\
|
|
224
|
+
"is the largest prime in primeslist.PRIMES\n" +\
|
|
225
|
+
"Currently, P = 3000.")
|
|
226
|
+
|
|
227
|
+
if initialise:
|
|
228
|
+
initPPOW(prime_max = max(marbles, urns))
|
|
229
|
+
|
|
230
|
+
p_value_frac, _ = conjMaxOrderStatistic(urnMax, marbles, urns)
|
|
231
|
+
p_value = p_value_frac.to_float()
|
|
232
|
+
if p_value <= 0:
|
|
233
|
+
# print(f"Warning: nonpositive p-value {p_value:.2e}")
|
|
234
|
+
# p_value = 1e-307
|
|
235
|
+
z_score = normal_isf_asymptotic(p_value_frac)
|
|
236
|
+
lp = None
|
|
237
|
+
else:
|
|
238
|
+
z_score = stats.norm.isf(p_value)
|
|
239
|
+
lp = -log(p_value)
|
|
240
|
+
return (p_value, z_score, lp)
|
|
241
|
+
|
|
242
|
+
def initPPOW(prime_max, pow_max = 1e100, metrics = False):
|
|
243
|
+
global PPOW
|
|
244
|
+
global MAXP
|
|
245
|
+
PPOW = []
|
|
246
|
+
MAXP = prime_max
|
|
247
|
+
|
|
248
|
+
if metrics:
|
|
249
|
+
size = 0
|
|
250
|
+
pow_start = perf_counter()
|
|
251
|
+
for prime in PRIMES:
|
|
252
|
+
if prime > MAXP:
|
|
253
|
+
break
|
|
254
|
+
pow_list = []
|
|
255
|
+
pk = prime
|
|
256
|
+
while pk < pow_max:
|
|
257
|
+
if metrics:
|
|
258
|
+
size += getsizeof(pk)
|
|
259
|
+
pow_list.append(pk)
|
|
260
|
+
pk = pk**2
|
|
261
|
+
PPOW.append(pow_list)
|
|
262
|
+
|
|
263
|
+
if metrics:
|
|
264
|
+
pow_end = perf_counter()
|
|
265
|
+
print(f"Prime time: {pow_end-pow_start:.3f}")
|
|
266
|
+
print(f"Prime memory: {size}")
|
|
267
|
+
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
def normal_isf_asymptotic(frac, iters=4):
|
|
271
|
+
L = gmpy2.log(frac.denominator) - gmpy2.log(frac.numerator)
|
|
272
|
+
x2 = 2 * L
|
|
273
|
+
for _ in range(iters):
|
|
274
|
+
x = sqrt(x2)
|
|
275
|
+
x2 = 2*L - 2*log(x) - log(2*pi) + 2/x2
|
|
276
|
+
return sqrt(x2)
|
|
277
|
+
|
|
278
|
+
def clear_caches():
|
|
279
|
+
maxOrderStatistic.cache_clear()
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
def cache_size_MB():
|
|
283
|
+
return maxOrderStatistic.cache_info()[3]/ (1024 * 1024)
|
|
284
|
+
|
|
285
|
+
def main():
|
|
286
|
+
initPPOW(100, metrics = True)
|
|
287
|
+
start = perf_counter()
|
|
288
|
+
PandZ(50, 100, 100) # Takes 1.6-1.7 seconds on a 3.8GHz Intel Core i5
|
|
289
|
+
end = perf_counter()
|
|
290
|
+
print(f"Total time: {end-start:.3f}")
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
main()
|