tpattern 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.
- tpattern/__init__.py +36 -0
- tpattern/advisor.py +195 -0
- tpattern/ci.py +300 -0
- tpattern/contrast.py +90 -0
- tpattern/detect.py +334 -0
- tpattern/diagnostics.py +120 -0
- tpattern/guided.py +372 -0
- tpattern/io.py +214 -0
- tpattern/methods.py +132 -0
- tpattern/pattern.py +129 -0
- tpattern/randomise.py +206 -0
- tpattern/report.py +201 -0
- tpattern/significance.py +218 -0
- tpattern/synthetic.py +95 -0
- tpattern/viz.py +107 -0
- tpattern-0.1.0.dist-info/METADATA +211 -0
- tpattern-0.1.0.dist-info/RECORD +20 -0
- tpattern-0.1.0.dist-info/WHEEL +5 -0
- tpattern-0.1.0.dist-info/licenses/LICENSE +21 -0
- tpattern-0.1.0.dist-info/top_level.txt +1 -0
tpattern/detect.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The T-pattern detection engine.
|
|
3
|
+
===============================
|
|
4
|
+
|
|
5
|
+
Bottom-up, level by level (Magnusson, 2000):
|
|
6
|
+
|
|
7
|
+
Level 0 Build a "terminal" pattern for every event *type* (one leaf per type).
|
|
8
|
+
These are the univariate patterns.
|
|
9
|
+
Level 1 Test every ordered pair of event types (A, B) for a critical
|
|
10
|
+
interval. Each accepted link becomes a bivariate pattern (A B).
|
|
11
|
+
Level k Treat every *retained* pattern as a symbol in its own right and test
|
|
12
|
+
it (on either side) against every terminal for a new critical
|
|
13
|
+
interval. Accepted links are one level deeper. Repeat until a level
|
|
14
|
+
adds nothing new.
|
|
15
|
+
|
|
16
|
+
Three THEME options handled here:
|
|
17
|
+
* Exclude Frequent Event-Types (threshold 1.50): before any linking, drop event
|
|
18
|
+
types whose mean number of occurrences *per observation* exceeds the
|
|
19
|
+
threshold, so they cannot act as universal connectors in multi-event
|
|
20
|
+
patterns. NOTE: this affects pattern *construction* only. Univariate
|
|
21
|
+
patterns (Level 0) list the full event alphabet and are reported regardless
|
|
22
|
+
(the paper: "univariate counts ... are not subject to the frequent
|
|
23
|
+
event-type exclusion").
|
|
24
|
+
* Minimum Occurrence (3): a pattern is only kept if it occurs at least 3 times.
|
|
25
|
+
* Completeness competition: a shorter pattern is discarded when it never
|
|
26
|
+
occurs *outside* a longer detected pattern — i.e. all its occurrences are
|
|
27
|
+
absorbed by a more complete pattern, so it carries no independent
|
|
28
|
+
information. This both matches Magnusson's "completeness" pruning and keeps
|
|
29
|
+
the hierarchy from proliferating redundant fragments.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from collections import defaultdict
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
|
|
37
|
+
from .ci import find_critical_interval
|
|
38
|
+
from .io import Observation
|
|
39
|
+
from .pattern import Instance, Pattern
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Config:
|
|
44
|
+
"""Detection settings. The defaults reproduce THEME's parameters, so most users
|
|
45
|
+
change only a couple of things:
|
|
46
|
+
|
|
47
|
+
* ``min_lag`` — set to 1 on frame-coded data (video timestamps), where events
|
|
48
|
+
sharing a timestamp are co-occurrence, not sequence. Default 0 = THEME's
|
|
49
|
+
behaviour (co-timing allowed).
|
|
50
|
+
* ``freq_exclude`` — the frequent-event exclusion (mean occurrences per
|
|
51
|
+
observation above which a type is barred from building patterns).
|
|
52
|
+
|
|
53
|
+
Not sure what to pick? ``advisor.recommend(observations)`` inspects the data and
|
|
54
|
+
suggests ``min_lag`` (and the null / error control used at ``calibrate``), with
|
|
55
|
+
the reasoning. Everything else can be left at its default.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
alpha: float = 0.005 # Significance Level
|
|
59
|
+
min_occurrence: int = 3 # Minimum Occurrence
|
|
60
|
+
max_edges: int | None = None # cap candidate CI edges (dense-data speedup)
|
|
61
|
+
min_lag: int = 0 # minimum A->B gap (ms). 0 = THEME behaviour
|
|
62
|
+
# (simultaneous events allowed); 1 = require
|
|
63
|
+
# a genuine temporal lag, so same-timestamp
|
|
64
|
+
# co-occurrences are excluded and handled
|
|
65
|
+
# separately as "simultaneity".
|
|
66
|
+
freq_exclude: float | None = 1.50 # auto exclude if mean/obs exceeds this
|
|
67
|
+
exclude_events: list[str] | None = None # explicit exclusion list (overrides
|
|
68
|
+
# the frequency rule when provided;
|
|
69
|
+
# [] disables exclusion entirely)
|
|
70
|
+
include_univariate: bool = True # Univariate Patterns = Include
|
|
71
|
+
completeness: bool = True # apply completeness competition
|
|
72
|
+
collapse_equivalent: bool = True # collapse occurrence-identical patterns (the
|
|
73
|
+
# two directions of a co-timed pair, or different bracketings of one chain)
|
|
74
|
+
# to a single representative, so counts and the multiple-comparison family
|
|
75
|
+
# reflect distinct hypotheses rather than perfectly-dependent duplicates.
|
|
76
|
+
lumping_factor: float | None = None # THEME Lumping Factor. When (A B) forms
|
|
77
|
+
# with forward conditional prob N_AB/N_A > this, B is removed from the rest
|
|
78
|
+
# of the search (it almost always follows A, so the pair is treated as a
|
|
79
|
+
# unit); likewise A if N_AB/N_B exceeds it. Controls combinatorial blow-up
|
|
80
|
+
# in dense/highly-structured data. None = off (default). Typical: 0.7-1.0.
|
|
81
|
+
min_samples_frac: float | None = None # THEME "Minimum % of Samples": a pattern
|
|
82
|
+
# must occur in at least this fraction of
|
|
83
|
+
# observations. Anti-monotone (a longer
|
|
84
|
+
# pattern can only span fewer observations),
|
|
85
|
+
# so it is pruned during the search.
|
|
86
|
+
max_level: int = 8 # safety cap on hierarchy depth
|
|
87
|
+
|
|
88
|
+
def is_excluded(self, event: str, mean_per_obs: float) -> bool:
|
|
89
|
+
"""Decide whether an event type is barred from *building* patterns.
|
|
90
|
+
|
|
91
|
+
Manual list (if given) takes precedence over the automatic frequency
|
|
92
|
+
rule — this mirrors THEME, where the analyst can override, and lets us
|
|
93
|
+
reproduce the paper's Saved analysis by keeping Interception in.
|
|
94
|
+
"""
|
|
95
|
+
if self.exclude_events is not None:
|
|
96
|
+
return event in self.exclude_events
|
|
97
|
+
if self.freq_exclude is None:
|
|
98
|
+
return False
|
|
99
|
+
return mean_per_obs > self.freq_exclude
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _group_pair(a: Pattern, b: Pattern) -> dict[int, tuple[list, list]]:
|
|
103
|
+
"""Group A- and B-instances by observation id, each sub-list time-sorted."""
|
|
104
|
+
idx: dict[int, tuple[list, list]] = defaultdict(lambda: ([], []))
|
|
105
|
+
for inst in a.instances:
|
|
106
|
+
idx[inst.obs][0].append(inst)
|
|
107
|
+
for inst in b.instances:
|
|
108
|
+
idx[inst.obs][1].append(inst)
|
|
109
|
+
for a_list, b_list in idx.values():
|
|
110
|
+
a_list.sort(key=lambda x: x.end)
|
|
111
|
+
b_list.sort(key=lambda x: x.start)
|
|
112
|
+
return idx
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Engine:
|
|
116
|
+
"""Runs a full T-pattern detection on one sample of observations."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, observations: list[Observation], config: Config | None = None):
|
|
119
|
+
self.obs = observations
|
|
120
|
+
self.cfg = config or Config()
|
|
121
|
+
self.total_time = sum(max(o.T, 1) for o in observations)
|
|
122
|
+
self.univariate: list[Pattern] = [] # all event types (for output)
|
|
123
|
+
self.terminals: list[Pattern] = [] # constructable terminals only
|
|
124
|
+
self.excluded: list[str] = []
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------- level 0
|
|
127
|
+
def build_terminals(self) -> list[Pattern]:
|
|
128
|
+
"""Create terminal patterns.
|
|
129
|
+
|
|
130
|
+
`univariate` = one per distinct event type (the full alphabet, reported
|
|
131
|
+
as Level-0 patterns). `terminals` = the subset usable to *build*
|
|
132
|
+
multi-event patterns (survive the frequent-event exclusion and reach the
|
|
133
|
+
minimum occurrence).
|
|
134
|
+
"""
|
|
135
|
+
by_type: dict[str, list[Instance]] = defaultdict(list)
|
|
136
|
+
token = 0
|
|
137
|
+
for i, o in enumerate(self.obs):
|
|
138
|
+
for t, ev in o.events:
|
|
139
|
+
# Every raw event occurrence gets a globally-unique token id so
|
|
140
|
+
# that composite patterns can never reuse the same event twice.
|
|
141
|
+
by_type[ev].append(Instance(obs=i, start=t, end=t, tokens=frozenset((token,))))
|
|
142
|
+
token += 1
|
|
143
|
+
|
|
144
|
+
n_obs = len(self.obs)
|
|
145
|
+
self.univariate, self.terminals, self.excluded = [], [], []
|
|
146
|
+
for ev, insts in sorted(by_type.items()):
|
|
147
|
+
term = Pattern(event=ev, instances=insts)
|
|
148
|
+
self.univariate.append(term)
|
|
149
|
+
if self.cfg.is_excluded(ev, len(insts) / n_obs):
|
|
150
|
+
self.excluded.append(ev)
|
|
151
|
+
continue
|
|
152
|
+
if len(insts) < self.cfg.min_occurrence:
|
|
153
|
+
continue
|
|
154
|
+
self.terminals.append(term)
|
|
155
|
+
return self.terminals
|
|
156
|
+
|
|
157
|
+
# ------------------------------------------------------- one link test
|
|
158
|
+
def _link(self, a: Pattern, b: Pattern) -> Pattern | None:
|
|
159
|
+
"""Test A -> B; return the composite pattern if a CI is significant."""
|
|
160
|
+
res = find_critical_interval(
|
|
161
|
+
a, b, _group_pair(a, b), self.total_time,
|
|
162
|
+
alpha=self.cfg.alpha, min_occurrence=self.cfg.min_occurrence,
|
|
163
|
+
min_lag=self.cfg.min_lag, max_edges=self.cfg.max_edges,
|
|
164
|
+
)
|
|
165
|
+
if res is None:
|
|
166
|
+
return None
|
|
167
|
+
return Pattern(
|
|
168
|
+
left=a, right=b, ci=(res.d1, res.d2),
|
|
169
|
+
p_value=res.p_value, instances=res.instances,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# -------------------------------------------- completeness competition
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _covered_by(sub: Pattern, sup: Pattern) -> bool:
|
|
175
|
+
"""True iff `sub` is a deterministic sub-part of `sup` — exact test.
|
|
176
|
+
|
|
177
|
+
Every occurrence carries the set of raw event tokens it is built from.
|
|
178
|
+
`sub` is absorbed by `sup` iff *every* occurrence of sub is built from a
|
|
179
|
+
subset of the tokens of some occurrence of sup, i.e. sub's events are
|
|
180
|
+
always a subset of sup's events. Then sub never occurs independently and
|
|
181
|
+
carries no information beyond sup — an exact, information-based redundancy
|
|
182
|
+
criterion, not a span-overlap heuristic.
|
|
183
|
+
|
|
184
|
+
Safety note for the frontier pruning: if sub is absorbed, then every A→B
|
|
185
|
+
that forms sub is already followed (inside sup) by the rest of sup, so
|
|
186
|
+
there is no free sub occurrence to extend differently — dropping it from
|
|
187
|
+
the search frontier cannot lose any extension.
|
|
188
|
+
"""
|
|
189
|
+
sup_by_obs: dict[int, list[frozenset]] = defaultdict(list)
|
|
190
|
+
for ins in sup.instances:
|
|
191
|
+
sup_by_obs[ins.obs].append(ins.tokens)
|
|
192
|
+
for ins in sub.instances:
|
|
193
|
+
toks = sup_by_obs.get(ins.obs)
|
|
194
|
+
if not toks:
|
|
195
|
+
return False
|
|
196
|
+
st = ins.tokens
|
|
197
|
+
if not any(st <= t for t in toks):
|
|
198
|
+
return False
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
def _completeness_competition(self, patterns: list[Pattern]) -> list[Pattern]:
|
|
202
|
+
"""Drop patterns whose every occurrence is absorbed by a longer one."""
|
|
203
|
+
composites = [p for p in patterns if p.level >= 1]
|
|
204
|
+
keep = []
|
|
205
|
+
for p in composites:
|
|
206
|
+
absorbed = False
|
|
207
|
+
for q in composites:
|
|
208
|
+
if q is p or q.length <= p.length:
|
|
209
|
+
continue
|
|
210
|
+
if self._covered_by(p, q):
|
|
211
|
+
absorbed = True
|
|
212
|
+
break
|
|
213
|
+
if not absorbed:
|
|
214
|
+
keep.append(p)
|
|
215
|
+
return keep
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------- occurrence-equivalence collapse
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _occ_key(p: Pattern):
|
|
220
|
+
"""A pattern's occurrence identity: the set of (observation, event-token
|
|
221
|
+
multiset) over its instances. Two composites with the same key describe the
|
|
222
|
+
SAME underlying occurrences — they are the same hypothesis written
|
|
223
|
+
differently (the two directions of a co-timed pair, or different bracketings
|
|
224
|
+
of one chain)."""
|
|
225
|
+
return frozenset((i.obs, tuple(sorted(i.tokens))) for i in p.instances)
|
|
226
|
+
|
|
227
|
+
def _collapse_equivalent(self, patterns: list[Pattern]) -> list[Pattern]:
|
|
228
|
+
"""Keep one deterministic representative per occurrence-equivalence class.
|
|
229
|
+
|
|
230
|
+
Occurrence-identical patterns are perfectly dependent: reporting or
|
|
231
|
+
error-correcting over all of them inflates the pattern count and the
|
|
232
|
+
multiple-comparison family without adding a distinct hypothesis. We keep the
|
|
233
|
+
lexicographically smallest signature in each class (deterministic, and the
|
|
234
|
+
same choice in real and surrogate data, so calibration stays consistent).
|
|
235
|
+
"""
|
|
236
|
+
groups: dict = {}
|
|
237
|
+
for p in patterns:
|
|
238
|
+
groups.setdefault(self._occ_key(p), []).append(p)
|
|
239
|
+
kept = [min(g, key=lambda p: p.signature()) for g in groups.values()]
|
|
240
|
+
kept.sort(key=lambda p: (p.level, p.signature()))
|
|
241
|
+
return kept
|
|
242
|
+
|
|
243
|
+
# --------------------------------------------------------- full search
|
|
244
|
+
def detect(self, verbose: bool = False) -> list[Pattern]:
|
|
245
|
+
"""Run the complete bottom-up detection and return all patterns.
|
|
246
|
+
|
|
247
|
+
Completeness competition is applied *per level*: a newly formed pattern
|
|
248
|
+
that is already absorbed by a longer pattern is not carried into the next
|
|
249
|
+
round, which both matches THEME's during-construction pruning and stops
|
|
250
|
+
redundant fragments (e.g. loop repeats among frequent passes) from
|
|
251
|
+
seeding an ever-growing frontier.
|
|
252
|
+
"""
|
|
253
|
+
if not self.terminals:
|
|
254
|
+
self.build_terminals()
|
|
255
|
+
|
|
256
|
+
# THEME "Minimum % of Samples": minimum number of distinct observations a
|
|
257
|
+
# pattern must span. Anti-monotone, so it prunes the search frontier.
|
|
258
|
+
min_bouts = 0
|
|
259
|
+
if self.cfg.min_samples_frac:
|
|
260
|
+
import math
|
|
261
|
+
min_bouts = math.ceil(self.cfg.min_samples_frac * len(self.obs))
|
|
262
|
+
|
|
263
|
+
# Cache each terminal's observation set for the bout co-occurrence prune.
|
|
264
|
+
term_obs = {id(t): {i.obs for i in t.instances} for t in self.terminals}
|
|
265
|
+
lump = self.cfg.lumping_factor
|
|
266
|
+
eliminated: set[str] = set() # terminal event names removed by lumping
|
|
267
|
+
|
|
268
|
+
composites: list[Pattern] = []
|
|
269
|
+
seen: set[str] = set()
|
|
270
|
+
|
|
271
|
+
# MAIN LOOP — build the hierarchy one level at a time. At each level we try
|
|
272
|
+
# to extend every pattern on the frontier by linking it (on either side)
|
|
273
|
+
# with every terminal; surviving new patterns seed the next level. The loop
|
|
274
|
+
# stops as soon as a level produces nothing new.
|
|
275
|
+
frontier: list[Pattern] = list(self.terminals) # patterns to extend
|
|
276
|
+
for level in range(self.cfg.max_level):
|
|
277
|
+
new: list[Pattern] = []
|
|
278
|
+
for p in frontier:
|
|
279
|
+
p_obs = {i.obs for i in p.instances}
|
|
280
|
+
for term in self.terminals:
|
|
281
|
+
if term.event in eliminated:
|
|
282
|
+
continue
|
|
283
|
+
# A->B can only occur in observations where both occur, so if
|
|
284
|
+
# they co-occur in too few, skip the expensive CI search entirely.
|
|
285
|
+
if min_bouts and len(p_obs & term_obs[id(term)]) < min_bouts:
|
|
286
|
+
continue
|
|
287
|
+
for a, b in ((p, term), (term, p)):
|
|
288
|
+
if a is b:
|
|
289
|
+
continue
|
|
290
|
+
combo = self._link(a, b)
|
|
291
|
+
if combo is None:
|
|
292
|
+
continue
|
|
293
|
+
if min_bouts and len({i.obs for i in combo.instances}) < min_bouts:
|
|
294
|
+
continue # too few observations; prune (anti-monotone)
|
|
295
|
+
sig = combo.signature()
|
|
296
|
+
if sig in seen:
|
|
297
|
+
continue
|
|
298
|
+
seen.add(sig)
|
|
299
|
+
new.append(combo)
|
|
300
|
+
# Lumping: if the pair is near-deterministic, drop the
|
|
301
|
+
# predictable component from the rest of the search.
|
|
302
|
+
if lump is not None:
|
|
303
|
+
if a.N and combo.N / a.N > lump and b.is_terminal:
|
|
304
|
+
eliminated.add(b.event)
|
|
305
|
+
if b.N and combo.N / b.N > lump and a.is_terminal:
|
|
306
|
+
eliminated.add(a.event)
|
|
307
|
+
if not new:
|
|
308
|
+
break
|
|
309
|
+
|
|
310
|
+
composites.extend(new)
|
|
311
|
+
if self.cfg.completeness:
|
|
312
|
+
# Keep only new patterns that are NOT already absorbed by a
|
|
313
|
+
# longer retained pattern; those seed the next level.
|
|
314
|
+
retained = set(id(p) for p in self._completeness_competition(composites))
|
|
315
|
+
frontier = [p for p in new if id(p) in retained]
|
|
316
|
+
else:
|
|
317
|
+
frontier = new
|
|
318
|
+
if verbose:
|
|
319
|
+
print(f" level {level + 1}: +{len(new)} new, "
|
|
320
|
+
f"{len(frontier)} carried forward "
|
|
321
|
+
f"(maxN={max((p.N for p in new), default=0)})")
|
|
322
|
+
if not frontier:
|
|
323
|
+
break
|
|
324
|
+
|
|
325
|
+
if self.cfg.completeness:
|
|
326
|
+
composites = self._completeness_competition(composites)
|
|
327
|
+
if self.cfg.collapse_equivalent:
|
|
328
|
+
composites = self._collapse_equivalent(composites)
|
|
329
|
+
|
|
330
|
+
result: list[Pattern] = []
|
|
331
|
+
if self.cfg.include_univariate:
|
|
332
|
+
result.extend(self.univariate)
|
|
333
|
+
result.extend(composites)
|
|
334
|
+
return result
|
tpattern/diagnostics.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data diagnostics — is the analytic (Poisson/NX-T) null even valid here?
|
|
3
|
+
======================================================================
|
|
4
|
+
|
|
5
|
+
The critical-interval test's analytic p-value rests on a specific assumption:
|
|
6
|
+
under the null, an event type's occurrences are scattered as a *homogeneous
|
|
7
|
+
Poisson process* over the observation window — equivalently, given N events in
|
|
8
|
+
a window [start, end], their times are distributed like N independent Uniform
|
|
9
|
+
draws on that window. The NX/T baseline density (N_x / T) is only the correct
|
|
10
|
+
null rate if that holds.
|
|
11
|
+
|
|
12
|
+
Rather than assume it, we test it, and let the result choose the method:
|
|
13
|
+
|
|
14
|
+
* If the times look uniform/Poisson -> the analytic per-window test is
|
|
15
|
+
calibrated; we then only need to correct for the *selection* over windows
|
|
16
|
+
and pairs (an analytic multiple-comparison correction).
|
|
17
|
+
* If the times are clustered/over-dispersed -> the analytic p is miscalibrated
|
|
18
|
+
and we MUST calibrate significance against surrogates end to end.
|
|
19
|
+
|
|
20
|
+
Two complementary checks:
|
|
21
|
+
|
|
22
|
+
1. Conditional-uniformity (Kolmogorov–Smirnov). For each event type, map every
|
|
23
|
+
occurrence to its position within its own window, u = (t - start)/(end-start)
|
|
24
|
+
in [0,1], pool across observations, and KS-test against Uniform(0,1). This is
|
|
25
|
+
the exact test of the "given N events, times ~ Uniform" property of a
|
|
26
|
+
homogeneous Poisson process. Rejection => not Poisson-uniform.
|
|
27
|
+
|
|
28
|
+
2. Index of dispersion. For each event type, take its per-observation counts and
|
|
29
|
+
form D = var/mean. A Poisson count has D = 1; D > 1 indicates clustering
|
|
30
|
+
(over-dispersion), D < 1 under-dispersion. Reported with a chi-square test.
|
|
31
|
+
|
|
32
|
+
`mean_u` (average within-window position) is also reported: values well above
|
|
33
|
+
0.5 mean the event type concentrates late in the sequence (e.g. shots), a direct
|
|
34
|
+
sign the uniform assumption fails.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from collections import defaultdict
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
|
|
42
|
+
import numpy as np
|
|
43
|
+
from scipy.stats import kstest, chi2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class TypeDiagnostic:
|
|
48
|
+
event: str
|
|
49
|
+
n: int # total occurrences
|
|
50
|
+
n_obs: int # observations containing it
|
|
51
|
+
mean_u: float # mean within-window position (0..1); 0.5 = uniform
|
|
52
|
+
ks_stat: float # KS distance from Uniform(0,1)
|
|
53
|
+
ks_p: float # KS p-value (small => not uniform)
|
|
54
|
+
dispersion: float # var/mean of per-observation counts (1 => Poisson)
|
|
55
|
+
disp_p: float # chi-square p for dispersion != 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def within_type_diagnostics(observations, min_count: int = 20) -> list[TypeDiagnostic]:
|
|
59
|
+
"""Run the conditional-uniformity and dispersion checks per event type."""
|
|
60
|
+
positions: dict[str, list[float]] = defaultdict(list)
|
|
61
|
+
counts: dict[str, list[int]] = defaultdict(list)
|
|
62
|
+
|
|
63
|
+
for o in observations:
|
|
64
|
+
span = max(o.end - o.start, 1)
|
|
65
|
+
per_obs_count: dict[str, int] = defaultdict(int)
|
|
66
|
+
for t, ev in o.events:
|
|
67
|
+
u = (t - o.start) / span
|
|
68
|
+
positions[ev].append(min(max(u, 0.0), 1.0))
|
|
69
|
+
per_obs_count[ev] += 1
|
|
70
|
+
# record a count for every type seen anywhere, including zeros later
|
|
71
|
+
for ev, c in per_obs_count.items():
|
|
72
|
+
counts[ev].append(c)
|
|
73
|
+
|
|
74
|
+
out: list[TypeDiagnostic] = []
|
|
75
|
+
n_obs_total = len(observations)
|
|
76
|
+
for ev, us in positions.items():
|
|
77
|
+
n = len(us)
|
|
78
|
+
if n < min_count:
|
|
79
|
+
continue
|
|
80
|
+
arr = np.asarray(us)
|
|
81
|
+
ks_stat, ks_p = kstest(arr, "uniform") # vs Uniform(0,1)
|
|
82
|
+
|
|
83
|
+
# Dispersion across ALL observations (missing => 0 occurrences): place the
|
|
84
|
+
# observed nonzero counts, the rest stay 0.
|
|
85
|
+
cc = np.array(counts[ev] + [0] * (n_obs_total - len(counts[ev])))
|
|
86
|
+
mean = cc.mean()
|
|
87
|
+
var = cc.var(ddof=1)
|
|
88
|
+
disp = var / mean if mean > 0 else float("nan")
|
|
89
|
+
# chi-square test that dispersion == 1 (var == mean): statistic
|
|
90
|
+
# (n_obs-1)*D ~ chi2_{n_obs-1} under Poisson.
|
|
91
|
+
dof = n_obs_total - 1
|
|
92
|
+
stat = dof * disp
|
|
93
|
+
disp_p = float(2 * min(chi2.cdf(stat, dof), 1 - chi2.cdf(stat, dof)))
|
|
94
|
+
|
|
95
|
+
out.append(TypeDiagnostic(
|
|
96
|
+
event=ev, n=n, n_obs=len(counts[ev]),
|
|
97
|
+
mean_u=float(arr.mean()), ks_stat=float(ks_stat), ks_p=float(ks_p),
|
|
98
|
+
dispersion=float(disp), disp_p=disp_p,
|
|
99
|
+
))
|
|
100
|
+
out.sort(key=lambda d: -d.n)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def summarise(diags: list[TypeDiagnostic], alpha: float = 0.05) -> dict:
|
|
105
|
+
"""High-level verdict across event types."""
|
|
106
|
+
n = len(diags)
|
|
107
|
+
non_uniform = sum(1 for d in diags if d.ks_p < alpha)
|
|
108
|
+
overdispersed = sum(1 for d in diags if d.dispersion > 1 and d.disp_p < alpha)
|
|
109
|
+
return {
|
|
110
|
+
"n_types_tested": n,
|
|
111
|
+
"n_non_uniform": non_uniform,
|
|
112
|
+
"frac_non_uniform": non_uniform / n if n else float("nan"),
|
|
113
|
+
"n_overdispersed": overdispersed,
|
|
114
|
+
"mean_dispersion": float(np.mean([d.dispersion for d in diags])) if n else float("nan"),
|
|
115
|
+
"verdict": (
|
|
116
|
+
"NON-POISSON: analytic null miscalibrated -> use surrogate calibration"
|
|
117
|
+
if n and non_uniform / n > 0.5 else
|
|
118
|
+
"Approximately Poisson: analytic test with selection correction may suffice"
|
|
119
|
+
),
|
|
120
|
+
}
|