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/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""tpattern — an auditable, open-source reimplementation of Magnusson's THEME
|
|
2
|
+
T-pattern detection, built to reproduce (and stress-test) THEME 8 results.
|
|
3
|
+
|
|
4
|
+
Reference: Magnusson, M. S. (2000). Discovering hidden time patterns in
|
|
5
|
+
behavior: T-patterns and their detection. Behavior Research Methods,
|
|
6
|
+
Instruments, & Computers, 32(1), 93-110.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .io import Observation, read_observation, read_sample, read_table
|
|
10
|
+
from .pattern import Instance, Pattern
|
|
11
|
+
from .ci import find_critical_interval, CIResult
|
|
12
|
+
from .detect import Config, Engine
|
|
13
|
+
from .randomise import run_null, NullResult, rotate, shuffle
|
|
14
|
+
from .significance import calibrate, CalibrationResult
|
|
15
|
+
from .report import patterns_table, forest_plot, report
|
|
16
|
+
from .viz import pattern_dendrogram, patterns_overview
|
|
17
|
+
from .advisor import recommend
|
|
18
|
+
from .methods import methods_text
|
|
19
|
+
from .guided import run_analysis, launch
|
|
20
|
+
from .contrast import group_contrast, contrast_items
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Observation", "read_observation", "read_sample", "read_table",
|
|
26
|
+
"Instance", "Pattern",
|
|
27
|
+
"find_critical_interval", "CIResult",
|
|
28
|
+
"Config", "Engine",
|
|
29
|
+
"run_null", "NullResult", "rotate", "shuffle",
|
|
30
|
+
"calibrate", "CalibrationResult",
|
|
31
|
+
"patterns_table", "forest_plot", "report",
|
|
32
|
+
"pattern_dendrogram", "patterns_overview",
|
|
33
|
+
"recommend", "methods_text",
|
|
34
|
+
"run_analysis", "launch",
|
|
35
|
+
"group_contrast", "contrast_items",
|
|
36
|
+
]
|
tpattern/advisor.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Method advisor — inspect the data, recommend the choices, articulate the method.
|
|
3
|
+
================================================================================
|
|
4
|
+
|
|
5
|
+
The tool exposes exactly three *questions* (not tuning knobs): which null, whether
|
|
6
|
+
to require a genuine lag, and which error-rate control. Rather than leave the user
|
|
7
|
+
to guess (THEME's failing), the advisor measures the properties of THIS dataset
|
|
8
|
+
that determine each choice, recommends one, and writes the sentence that justifies
|
|
9
|
+
it — so the Methods section is generated from the data, not asserted.
|
|
10
|
+
|
|
11
|
+
1. Null (N1 rotation/shuffle vs N2 profile-preserving)
|
|
12
|
+
Driver: does each event type have its own marginal temporal profile?
|
|
13
|
+
- Measured by conditional-uniformity (KS vs Uniform of within-window
|
|
14
|
+
positions). If most types are non-uniform, marginal timing is real and
|
|
15
|
+
would masquerade as coupling under N1, so N2 is required. If types are
|
|
16
|
+
~uniform, N1 and N2 coincide and the choice is moot.
|
|
17
|
+
|
|
18
|
+
2. Minimum lag (0 = concurrency allowed vs >=1 = genuine sequence only)
|
|
19
|
+
Driver: how often do consecutive events share a timestamp, and at what
|
|
20
|
+
resolution? A high same-unit co-occurrence fraction means within-unit order
|
|
21
|
+
is undefined, so directional patterns need a real lag.
|
|
22
|
+
|
|
23
|
+
3. Error control (FWER confirmatory vs FDR exploratory)
|
|
24
|
+
Driver: this is primarily the user's claim type, but sample size informs
|
|
25
|
+
power. We report both and recommend FDR for discovery, FWER for a small
|
|
26
|
+
number of strong confirmatory claims.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
|
|
33
|
+
from .diagnostics import within_type_diagnostics, summarise
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Choice:
|
|
38
|
+
option: str
|
|
39
|
+
recommended: str
|
|
40
|
+
rationale: str # technical justification (for the methods)
|
|
41
|
+
plain: str = "" # the same reason in plain English
|
|
42
|
+
evidence: dict = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Recommendation:
|
|
47
|
+
n_obs: int
|
|
48
|
+
n_events: int
|
|
49
|
+
choices: list[Choice]
|
|
50
|
+
|
|
51
|
+
def __str__(self) -> str:
|
|
52
|
+
lines = [f"Dataset: {self.n_obs} observations, {self.n_events} events\n"]
|
|
53
|
+
for c in self.choices:
|
|
54
|
+
lines.append(f"[{c.option}] -> {c.recommended}\n {c.rationale}")
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
def methods_text(self) -> str:
|
|
58
|
+
"""A ready-to-adapt Methods paragraph, grounded in the measured values."""
|
|
59
|
+
return " ".join(c.rationale for c in self.choices)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolution_stats(observations) -> dict:
|
|
63
|
+
"""Timestamp resolution and same-unit co-occurrence rate."""
|
|
64
|
+
min_gap = None
|
|
65
|
+
same = 0
|
|
66
|
+
total = 0
|
|
67
|
+
for o in observations:
|
|
68
|
+
ts = sorted(t for t, _ in o.events)
|
|
69
|
+
for i in range(len(ts) - 1):
|
|
70
|
+
total += 1
|
|
71
|
+
d = ts[i + 1] - ts[i]
|
|
72
|
+
if d == 0:
|
|
73
|
+
same += 1
|
|
74
|
+
elif min_gap is None or d < min_gap:
|
|
75
|
+
min_gap = d
|
|
76
|
+
return {
|
|
77
|
+
"min_nonzero_gap": min_gap,
|
|
78
|
+
"same_timestamp_frac": (same / total) if total else 0.0,
|
|
79
|
+
"n_consecutive": total,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def recommend(observations, *, min_count: int = 15,
|
|
84
|
+
uniformity_alpha: float = 0.05) -> Recommendation:
|
|
85
|
+
"""Inspect the data and recommend null, min_lag and error-control, with text."""
|
|
86
|
+
n_obs = len(observations)
|
|
87
|
+
n_events = sum(len(o.events) for o in observations)
|
|
88
|
+
|
|
89
|
+
# --- Null choice: marginal temporal structure? ---
|
|
90
|
+
diag = within_type_diagnostics(observations, min_count=min_count)
|
|
91
|
+
summ = summarise(diag, alpha=uniformity_alpha)
|
|
92
|
+
frac_nu = summ.get("frac_non_uniform", 0.0)
|
|
93
|
+
# illustrative extreme (type furthest from centred placement)
|
|
94
|
+
extreme = max(diag, key=lambda d: abs(d.mean_u - 0.5), default=None)
|
|
95
|
+
ex_txt = ""
|
|
96
|
+
if extreme is not None:
|
|
97
|
+
where = "late" if extreme.mean_u > 0.5 else "early"
|
|
98
|
+
ex_txt = (f" (e.g. {extreme.event} concentrated {where} in the sequence, "
|
|
99
|
+
f"mean position {extreme.mean_u:.2f})")
|
|
100
|
+
|
|
101
|
+
if summ["n_types_tested"] and frac_nu > 0.5:
|
|
102
|
+
null_choice = Choice(
|
|
103
|
+
"Null", "N2 profile-preserving",
|
|
104
|
+
rationale=(f"{summ['n_non_uniform']} of {summ['n_types_tested']} event "
|
|
105
|
+
f"types deviated from uniform temporal placement "
|
|
106
|
+
f"(KS p<{uniformity_alpha}){ex_txt}, so the profile-preserving "
|
|
107
|
+
f"null (N2) was used to isolate cross-event coupling from each "
|
|
108
|
+
f"type's marginal timing; the rotation null (N1) is reported "
|
|
109
|
+
f"alongside to quantify the marginal-timing contribution."),
|
|
110
|
+
plain=("<b>Testing patterns against chance — without being fooled by "
|
|
111
|
+
"timing.</b> Some actions naturally tend to happen at particular "
|
|
112
|
+
"points (for example, shots come late in a move), so two actions can "
|
|
113
|
+
"look 'linked' simply because they tend to occur at similar times — "
|
|
114
|
+
"not because one leads to the other. To guard against this, we "
|
|
115
|
+
"compare each of your patterns against many reshuffled copies of your "
|
|
116
|
+
"data that keep each action's own natural timing but scramble the "
|
|
117
|
+
"links between actions; a pattern is kept only if it happens more "
|
|
118
|
+
"often than in those reshuffles. <i>Why it's good:</i> the patterns "
|
|
119
|
+
"you end up with reflect genuine connections between actions, not "
|
|
120
|
+
"accidents of timing."),
|
|
121
|
+
evidence={"frac_non_uniform": frac_nu, **summ},
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
null_choice = Choice(
|
|
125
|
+
"Null", "N1 rotation (N2 confirms)",
|
|
126
|
+
rationale=(f"Only {summ.get('n_non_uniform',0)} of "
|
|
127
|
+
f"{summ.get('n_types_tested',0)} event types showed marginal "
|
|
128
|
+
f"temporal structure, so the rotation (N1) and profile-preserving "
|
|
129
|
+
f"(N2) nulls effectively coincide; N1 was used with N2 reported "
|
|
130
|
+
f"to confirm equivalence."),
|
|
131
|
+
plain=("How we test against chance: your actions are fairly evenly spread "
|
|
132
|
+
"in time, so we compare each pattern to simple time-shifted copies "
|
|
133
|
+
"of your data — a fair test when timing isn't skewed."),
|
|
134
|
+
evidence={"frac_non_uniform": frac_nu, **summ},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# --- Minimum lag: resolution and co-occurrence ---
|
|
138
|
+
res = _resolution_stats(observations)
|
|
139
|
+
stf = res["same_timestamp_frac"]
|
|
140
|
+
gap = res["min_nonzero_gap"]
|
|
141
|
+
if stf > 0.10:
|
|
142
|
+
lag_choice = Choice(
|
|
143
|
+
"Minimum lag", "min_lag = 1 (require genuine lag)",
|
|
144
|
+
rationale=(f"{stf:.0%} of consecutive events shared a timestamp at the "
|
|
145
|
+
f"data's resolution ({gap} time-units minimum gap), so order "
|
|
146
|
+
f"within a unit is undefined; a genuine lag was required "
|
|
147
|
+
f"(min_lag=1) and same-unit co-occurrences were tabulated "
|
|
148
|
+
f"separately as concurrency rather than sequence."),
|
|
149
|
+
plain=(f"<b>Is it a sequence, or two things happening at once?</b> "
|
|
150
|
+
f"{stf:.0%} of your events share the exact same timestamp — coded at "
|
|
151
|
+
f"the same instant (e.g. the same video frame). <i>Why this "
|
|
152
|
+
f"matters:</i> when two actions happen at the same moment, there is no "
|
|
153
|
+
f"way to know which came first, so treating one as a 'follow-on' from "
|
|
154
|
+
f"the other would invent an order that isn't really in your data and "
|
|
155
|
+
f"could turn things-happening-together into fake sequences. <i>What we "
|
|
156
|
+
f"do:</i> we count one action as following another only when there is "
|
|
157
|
+
f"a real gap in time between them; same-instant actions are reported "
|
|
158
|
+
f"as happening together, not as a sequence. <i>Why it's good:</i> "
|
|
159
|
+
f"it's a safeguard — a few apparent patterns built only on "
|
|
160
|
+
f"same-instant events drop out (correctly, because they were never "
|
|
161
|
+
f"sequences), so your conclusions are stronger, not weaker."),
|
|
162
|
+
evidence=res,
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
lag_choice = Choice(
|
|
166
|
+
"Minimum lag", "min_lag = 0 (concurrency negligible)",
|
|
167
|
+
rationale=(f"Only {stf:.0%} of consecutive events shared a timestamp, so "
|
|
168
|
+
f"concurrency is negligible and no minimum lag was imposed."),
|
|
169
|
+
plain=(f"<b>Is it a sequence, or two things happening at once?</b> Only "
|
|
170
|
+
f"{stf:.0%} of your events share a timestamp, so their order is "
|
|
171
|
+
f"reliable — we can trust which came first — and every event is "
|
|
172
|
+
f"kept."),
|
|
173
|
+
evidence=res,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# --- Error control ---
|
|
177
|
+
err_choice = Choice(
|
|
178
|
+
"Error control", "FDR primary, FWER reported",
|
|
179
|
+
rationale=(f"Across {n_obs} observations, pattern significance was calibrated "
|
|
180
|
+
f"against the null and controlled by false-discovery rate "
|
|
181
|
+
f"(Benjamini-Hochberg, q=0.05) for discovery, with family-wise "
|
|
182
|
+
f"control (alpha=0.005) additionally reported for confirmatory claims."),
|
|
183
|
+
plain=("<b>Guarding against false alarms.</b> When many patterns are tested at "
|
|
184
|
+
"once, some will look 'significant' just by luck. We use the "
|
|
185
|
+
"<b>false-discovery rate (FDR)</b> — a standard method that keeps the "
|
|
186
|
+
"share of these lucky flukes among your flagged results low (under 5%). "
|
|
187
|
+
"<i>Impact:</i> the patterns marked 'robust' are ones you can rely on, "
|
|
188
|
+
"not chance findings. A stricter check (the family-wise rate) is also "
|
|
189
|
+
"reported for when you want to be extra cautious about one specific "
|
|
190
|
+
"pattern."),
|
|
191
|
+
evidence={"n_obs": n_obs},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return Recommendation(n_obs=n_obs, n_events=n_events,
|
|
195
|
+
choices=[null_choice, lag_choice, err_choice])
|
tpattern/ci.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The critical-interval (CI) test — the mathematical core.
|
|
3
|
+
========================================================
|
|
4
|
+
|
|
5
|
+
This is the single statistical test that the whole method is built on. It asks,
|
|
6
|
+
for two patterns A and B:
|
|
7
|
+
|
|
8
|
+
"Is there a time window [d1, d2] such that, after an occurrence of A, an
|
|
9
|
+
occurrence of B follows within that window *more often than chance would
|
|
10
|
+
predict*?"
|
|
11
|
+
|
|
12
|
+
If yes, (A B) is a real T-pattern and [d1, d2] is its **critical interval**.
|
|
13
|
+
|
|
14
|
+
The null hypothesis (Magnusson, 2000)
|
|
15
|
+
-------------------------------------
|
|
16
|
+
Under H0, A and B are unrelated and B is scattered at random over the observed
|
|
17
|
+
time. THEME's "NX/T" baseline states the density of B directly from the data:
|
|
18
|
+
|
|
19
|
+
p1 = N_B / T (probability B occupies any given 1 ms time unit)
|
|
20
|
+
|
|
21
|
+
where N_B = total occurrences of B across the whole sample
|
|
22
|
+
T = total observed time across the whole sample (sum of windows)
|
|
23
|
+
|
|
24
|
+
For a candidate window of width w = d2 - d1 + 1 placed just after an A, the
|
|
25
|
+
probability that *at least one* B lands inside it is
|
|
26
|
+
|
|
27
|
+
p_hit = 1 - (1 - p1) ** w (Eq. 1)
|
|
28
|
+
|
|
29
|
+
If A occurs N_A times, and the A's were unrelated to the B's, then the number k
|
|
30
|
+
of A's that happen to be followed by a B in the window is Binomial(N_A, p_hit).
|
|
31
|
+
So the chance of seeing k *or more* hits by luck is the upper tail
|
|
32
|
+
|
|
33
|
+
p_value = P(X >= k), X ~ Binomial(N_A, p_hit) (Eq. 2)
|
|
34
|
+
|
|
35
|
+
Among all windows whose p_value is below the significance level (0.005 here) and
|
|
36
|
+
which contain at least `min_occurrence` hits, we keep the one capturing the
|
|
37
|
+
**most occurrences** — the *largest significant* critical interval. This is
|
|
38
|
+
Magnusson's notion of the critical interval: the widest window within which B
|
|
39
|
+
still follows A significantly more often than chance. It yields the most
|
|
40
|
+
*complete* pattern (all real instances), rather than the tightest sub-window.
|
|
41
|
+
The significance test itself guards against runaway widening: a wider window has
|
|
42
|
+
a higher chance baseline `p_hit`, so it can only stay significant while genuine
|
|
43
|
+
hits keep pace. Ties in occurrence-count are broken toward the more significant,
|
|
44
|
+
then tighter, interval.
|
|
45
|
+
|
|
46
|
+
"Free" critical-interval search
|
|
47
|
+
-------------------------------
|
|
48
|
+
THEME offers "Fast" (d1 forced to 0, only d2 searched) and "Free" (both edges
|
|
49
|
+
searched). The paper used Free, so both d1 and d2 range over the set of observed
|
|
50
|
+
A→B gaps. Only real observed gaps can change k, so they are the only candidate
|
|
51
|
+
edges we ever need to try — this makes the "search all windows" exact, not a
|
|
52
|
+
grid approximation.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
from bisect import bisect_left
|
|
58
|
+
from dataclasses import dataclass
|
|
59
|
+
|
|
60
|
+
import numpy as np
|
|
61
|
+
from scipy.special import betainc
|
|
62
|
+
|
|
63
|
+
from .pattern import Instance, Pattern
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def binom_sf(k: int, n: int, p: float) -> float:
|
|
67
|
+
"""Upper-tail binomial probability P(X >= k) for X ~ Binomial(n, p).
|
|
68
|
+
|
|
69
|
+
Uses the regularised incomplete beta identity
|
|
70
|
+
P(X >= k) = I_p(k, n - k + 1)
|
|
71
|
+
which is a single scalar C call, ~30x faster than scipy.stats.binom.sf and
|
|
72
|
+
numerically identical for our purposes.
|
|
73
|
+
"""
|
|
74
|
+
if k <= 0:
|
|
75
|
+
return 1.0
|
|
76
|
+
if k > n:
|
|
77
|
+
return 0.0
|
|
78
|
+
return float(betainc(k, n - k + 1, p))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class CIResult:
|
|
83
|
+
"""Outcome of a successful critical-interval search."""
|
|
84
|
+
|
|
85
|
+
d1: int
|
|
86
|
+
d2: int
|
|
87
|
+
p_value: float
|
|
88
|
+
instances: list[Instance] # the matched (A→B) occurrences
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def N(self) -> int:
|
|
92
|
+
return len(self.instances)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _match_obs(a_ends, a_tok, b_ends, b_starts, b_tok, d1, d2, collect):
|
|
96
|
+
"""Greedy one-to-one A→B matching within a single observation's window.
|
|
97
|
+
|
|
98
|
+
Interval-scheduling greedy: walk A's in end-time order and give each the
|
|
99
|
+
earliest still-unused B whose start lies in [a.end+d1, a.end+d2]. Every event
|
|
100
|
+
is consumed at most once (tracked with a `used` bitmask, allocation-free).
|
|
101
|
+
|
|
102
|
+
`used` bit j marks B index j taken. Because A-ends are ascending, the lower
|
|
103
|
+
scan pointer `j0` only moves forward across A's. The distinct-token check is
|
|
104
|
+
only needed when gap == 0 (a B strictly after A's end cannot share an event
|
|
105
|
+
with A); this fast-path skips the frozenset test for the common case.
|
|
106
|
+
|
|
107
|
+
If `collect` is False we only count; if True we also return the (start, end,
|
|
108
|
+
tokens) of each matched occurrence.
|
|
109
|
+
"""
|
|
110
|
+
nb = len(b_starts)
|
|
111
|
+
used = 0
|
|
112
|
+
j0 = 0
|
|
113
|
+
count = 0
|
|
114
|
+
matched = [] if collect else None
|
|
115
|
+
for ai in range(len(a_ends)):
|
|
116
|
+
ae = a_ends[ai]
|
|
117
|
+
lo = ae + d1
|
|
118
|
+
hi = ae + d2
|
|
119
|
+
while j0 < nb and b_starts[j0] < lo:
|
|
120
|
+
j0 += 1
|
|
121
|
+
j = j0
|
|
122
|
+
while j < nb and b_starts[j] <= hi:
|
|
123
|
+
if not (used >> j) & 1:
|
|
124
|
+
if b_starts[j] > ae or a_tok[ai].isdisjoint(b_tok[j]):
|
|
125
|
+
used |= 1 << j
|
|
126
|
+
count += 1
|
|
127
|
+
if collect:
|
|
128
|
+
matched.append((ae, ai, j))
|
|
129
|
+
break
|
|
130
|
+
j += 1
|
|
131
|
+
return (count, matched) if collect else count
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def find_critical_interval(
|
|
135
|
+
a: Pattern,
|
|
136
|
+
b: Pattern,
|
|
137
|
+
obs_index: dict[int, tuple],
|
|
138
|
+
total_time: int,
|
|
139
|
+
alpha: float = 0.005,
|
|
140
|
+
min_occurrence: int = 3,
|
|
141
|
+
min_lag: int = 0,
|
|
142
|
+
max_edges: int | None = None,
|
|
143
|
+
) -> CIResult | None:
|
|
144
|
+
"""Search for the most significant critical interval linking A then B.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
a, b : Pattern
|
|
149
|
+
The two patterns to test for an ``A -> B`` relationship.
|
|
150
|
+
obs_index : dict[obs_id -> (a_in_obs, b_in_obs)]
|
|
151
|
+
A- and B-instances grouped by observation, each list sorted by time.
|
|
152
|
+
Grouping by observation enforces that a pattern never spans two files.
|
|
153
|
+
total_time : int
|
|
154
|
+
Sum of all observation window lengths T (denominator of the baseline).
|
|
155
|
+
alpha, min_occurrence : float, int
|
|
156
|
+
THEME's Significance Level and Minimum Occurrence.
|
|
157
|
+
|
|
158
|
+
Returns
|
|
159
|
+
-------
|
|
160
|
+
CIResult | None
|
|
161
|
+
The best significant interval, or None if no window qualifies.
|
|
162
|
+
"""
|
|
163
|
+
N_A = a.N
|
|
164
|
+
N_B = b.N
|
|
165
|
+
if N_A < min_occurrence or N_B < min_occurrence:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
p1 = N_B / total_time # NX/T baseline density
|
|
169
|
+
if p1 <= 0 or p1 >= 1:
|
|
170
|
+
return None
|
|
171
|
+
# Baseline assumptions (all standard for the analytic T-pattern test, and all
|
|
172
|
+
# relaxed by the surrogate calibration in significance.py, where this p-value
|
|
173
|
+
# serves only as a strength-ordering statistic):
|
|
174
|
+
# * Homogeneous-Poisson null: p_hit = 1-(1-p1)^w is the discrete-Bernoulli
|
|
175
|
+
# form of 1-exp(-p1*w); it assumes B is uniformly/independently placed.
|
|
176
|
+
# The diagnostics module shows this is violated (events are clustered),
|
|
177
|
+
# which is exactly why final significance is set by surrogates, not here.
|
|
178
|
+
# NOTE ON FORM: this "NX/T" reading, 1-(1-N_B/T)^w, and Magnusson's published
|
|
179
|
+
# form, 1-(1-w/T)^N_B, are both first-order approximations of 1-exp(-N_B*w/T)
|
|
180
|
+
# and coincide to <=4e-4 in the pooled-millisecond regime used here (T~1e6);
|
|
181
|
+
# they reproduce THEME's anchor counts exactly. Which form THEME uses
|
|
182
|
+
# internally cannot be confirmed from outside.
|
|
183
|
+
# * Independent trials: k ~ Binomial(N_A, p_hit) treats the N_A A-instances
|
|
184
|
+
# as independent, though they share one B pool (greedy consumes B's).
|
|
185
|
+
# * No right-censoring correction: A-instances near an observation's end
|
|
186
|
+
# have less than a full window w available, so p_hit is a mild
|
|
187
|
+
# over-estimate there (slightly anti-conservative).
|
|
188
|
+
|
|
189
|
+
# --- Precompute per-observation arrays once (a sorted by end, b by start) ---
|
|
190
|
+
# Also cheaply bound the maximum achievable occurrence count: in each
|
|
191
|
+
# observation at most min(#A, #B) pairs can be formed. If the sum of those
|
|
192
|
+
# caps cannot reach the minimum occurrence, no interval ever will, so we skip
|
|
193
|
+
# the whole search — this prunes the many pattern pairs that barely co-occur.
|
|
194
|
+
per_obs = []
|
|
195
|
+
a_gap_lists: list[list[int]] = [] # per A-occurrence: sorted acceptable B-gaps
|
|
196
|
+
gaps: set[int] = set()
|
|
197
|
+
max_possible = 0
|
|
198
|
+
for a_list, b_list in obs_index.values():
|
|
199
|
+
if not a_list or not b_list:
|
|
200
|
+
continue
|
|
201
|
+
a_srt = sorted(a_list, key=lambda x: x.end)
|
|
202
|
+
b_srt = sorted(b_list, key=lambda x: x.start)
|
|
203
|
+
a_ends = [x.end for x in a_srt]
|
|
204
|
+
a_tok = [x.tokens for x in a_srt]
|
|
205
|
+
a_starts = [x.start for x in a_srt]
|
|
206
|
+
b_starts = [x.start for x in b_srt]
|
|
207
|
+
b_ends = [x.end for x in b_srt]
|
|
208
|
+
b_tok = [x.tokens for x in b_srt]
|
|
209
|
+
obs_id = a_srt[0].obs
|
|
210
|
+
per_obs.append((obs_id, a_ends, a_starts, a_tok, b_ends, b_starts, b_tok))
|
|
211
|
+
max_possible += min(len(a_ends), len(b_ends))
|
|
212
|
+
for ai, ae in enumerate(a_ends):
|
|
213
|
+
row = [bs - ae for k, bs in enumerate(b_starts)
|
|
214
|
+
if bs - ae >= min_lag and (bs > ae or a_tok[ai].isdisjoint(b_tok[k]))]
|
|
215
|
+
if row:
|
|
216
|
+
row.sort()
|
|
217
|
+
a_gap_lists.append(row)
|
|
218
|
+
gaps.update(row)
|
|
219
|
+
if max_possible < min_occurrence or not gaps:
|
|
220
|
+
return None
|
|
221
|
+
edges = sorted(gaps)
|
|
222
|
+
# For dense event streams the distinct-gap set can be enormous, making the
|
|
223
|
+
# exact O(g^2) window search costly. Optionally subsample to `max_edges`
|
|
224
|
+
# rank-spaced candidate edges (keeping the extremes) — a bounded approximation
|
|
225
|
+
# of the interval boundaries, akin to THEME's non-exhaustive "Free Heuristic".
|
|
226
|
+
if max_edges and len(edges) > max_edges:
|
|
227
|
+
step = len(edges) / max_edges
|
|
228
|
+
idx = sorted({int(i * step) for i in range(max_edges)} | {len(edges) - 1})
|
|
229
|
+
edges = [edges[i] for i in idx]
|
|
230
|
+
|
|
231
|
+
# --- Stage 1: cheap count-A upper bound. For each window, count-A = number of
|
|
232
|
+
# A-occurrences with >=1 acceptable B inside it (B reusable). Since greedy
|
|
233
|
+
# (one-to-one) can only match FEWER, count-A >= greedy_k, and significance
|
|
234
|
+
# strengthens with k, so a window that is not count-A-significant can never be
|
|
235
|
+
# greedy-significant. We collect only the count-A-significant windows as
|
|
236
|
+
# candidates — for most pattern pairs there are none, and greedy is skipped
|
|
237
|
+
# entirely. This is an exact gate, not an approximation. ---
|
|
238
|
+
candidates = [] # (countA_k, d1, d2)
|
|
239
|
+
edges_np = np.asarray(edges, dtype=np.int64)
|
|
240
|
+
for i, d1 in enumerate(edges):
|
|
241
|
+
# nearest acceptable B-gap >= d1 for each A-occurrence
|
|
242
|
+
fs = [row[j] for row in a_gap_lists
|
|
243
|
+
if (j := bisect_left(row, d1)) < len(row)]
|
|
244
|
+
if len(fs) < min_occurrence:
|
|
245
|
+
continue
|
|
246
|
+
fs_np = np.sort(np.asarray(fs, dtype=np.int64))
|
|
247
|
+
d2s = edges_np[i:] # candidate right edges
|
|
248
|
+
kA = np.searchsorted(fs_np, d2s, side="right") # count fs <= d2
|
|
249
|
+
m = kA >= min_occurrence
|
|
250
|
+
if not m.any():
|
|
251
|
+
continue
|
|
252
|
+
d2m, kAm = d2s[m], kA[m]
|
|
253
|
+
w = (d2m - d1 + 1).astype(float)
|
|
254
|
+
p_hit = 1.0 - (1.0 - p1) ** w
|
|
255
|
+
# vectorised P(X >= kA) = I_{p_hit}(kA, N_A - kA + 1)
|
|
256
|
+
pv = betainc(kAm, N_A - kAm + 1, p_hit)
|
|
257
|
+
sig = pv < alpha
|
|
258
|
+
for d2v, kAv in zip(d2m[sig].tolist(), kAm[sig].tolist()):
|
|
259
|
+
candidates.append((int(kAv), d1, int(d2v)))
|
|
260
|
+
if not candidates:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
# --- Stage 2: exact greedy only on candidate windows, largest count-A first.
|
|
264
|
+
# Once the best confirmed greedy count can't be beaten by a candidate's upper
|
|
265
|
+
# bound, stop. ---
|
|
266
|
+
candidates.sort(key=lambda c: -c[0])
|
|
267
|
+
best_key = None
|
|
268
|
+
best_d = None
|
|
269
|
+
for kA, d1, d2 in candidates:
|
|
270
|
+
if best_key is not None and kA < -best_key[0]:
|
|
271
|
+
break # upper bound below best greedy k → done
|
|
272
|
+
k = 0
|
|
273
|
+
for (_oid, a_ends, _as, a_tok, _be, b_starts, b_tok) in per_obs:
|
|
274
|
+
k += _match_obs(a_ends, a_tok, _be, b_starts, b_tok, d1, d2, False)
|
|
275
|
+
if k < min_occurrence:
|
|
276
|
+
continue
|
|
277
|
+
w = d2 - d1 + 1
|
|
278
|
+
p_hit = 1.0 - (1.0 - p1) ** w # Eq. 1
|
|
279
|
+
p_value = binom_sf(k, N_A, p_hit) # Eq. 2, P(X >= k)
|
|
280
|
+
if p_value >= alpha:
|
|
281
|
+
continue
|
|
282
|
+
key = (-k, p_value, w, d1)
|
|
283
|
+
if best_key is None or key < best_key:
|
|
284
|
+
best_key = key
|
|
285
|
+
best_d = (d1, d2, p_value)
|
|
286
|
+
|
|
287
|
+
if best_d is None:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
# --- Rebuild the matched occurrences for the winning window only. ---
|
|
291
|
+
d1, d2, p_value = best_d
|
|
292
|
+
instances: list[Instance] = []
|
|
293
|
+
for (oid, a_ends, a_starts, a_tok, b_ends, b_starts, b_tok) in per_obs:
|
|
294
|
+
_, matched = _match_obs(a_ends, a_tok, b_ends, b_starts, b_tok, d1, d2, True)
|
|
295
|
+
for (ae, ai, j) in matched:
|
|
296
|
+
instances.append(Instance(
|
|
297
|
+
obs=oid, start=a_starts[ai], end=b_ends[j],
|
|
298
|
+
tokens=a_tok[ai] | b_tok[j],
|
|
299
|
+
))
|
|
300
|
+
return CIResult(d1=d1, d2=d2, p_value=p_value, instances=instances)
|
tpattern/contrast.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Group contrast — does a pattern occur more in one group than another?
|
|
3
|
+
=====================================================================
|
|
4
|
+
|
|
5
|
+
Promotes the recurring "winner vs loser" / "goal vs non-goal" analysis to a
|
|
6
|
+
one-call library function. Detects T-patterns on the pooled sample, then for each
|
|
7
|
+
pattern counts the observations in each group that contain it and tests the
|
|
8
|
+
difference with Fisher's exact test and an odds ratio with a 95% confidence
|
|
9
|
+
interval. The output feeds `forest_plot` directly.
|
|
10
|
+
|
|
11
|
+
from tpattern import group_contrast, forest_plot
|
|
12
|
+
rows = group_contrast(observations, group_of=winner_or_loser)
|
|
13
|
+
forest_plot(contrast_items(rows), "contrast.png")
|
|
14
|
+
|
|
15
|
+
`group_of` is either a dict {observation_name: label} or a callable taking an
|
|
16
|
+
Observation and returning its label. Exactly two groups are compared.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
|
|
23
|
+
from .detect import Config, Engine
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _or_ci(a, b, c, d):
|
|
27
|
+
"""Odds ratio and 95% CI for the 2x2 table [[a, b], [c, d]] with a Haldane
|
|
28
|
+
+0.5 correction so zero cells don't blow up."""
|
|
29
|
+
a, b, c, d = a + 0.5, b + 0.5, c + 0.5, d + 0.5
|
|
30
|
+
orr = (a * d) / (b * c)
|
|
31
|
+
se = math.sqrt(1 / a + 1 / b + 1 / c + 1 / d)
|
|
32
|
+
return orr, math.exp(math.log(orr) - 1.96 * se), math.exp(math.log(orr) + 1.96 * se)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def group_contrast(observations, group_of, config: Config | None = None, *,
|
|
36
|
+
groups=None, min_count: int = 5):
|
|
37
|
+
"""Compare T-pattern occurrence between two groups of observations.
|
|
38
|
+
|
|
39
|
+
Returns a list of row dicts (one per pattern), sorted by p-value, each with
|
|
40
|
+
the per-group counts, odds ratio, 95% CI, Fisher p, and which group the
|
|
41
|
+
pattern is more common in. Counts are of *observations* containing the pattern
|
|
42
|
+
at least once (not raw occurrences), so the comparison is a clean 2x2.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
from scipy.stats import fisher_exact
|
|
46
|
+
except ImportError as e: # pragma: no cover
|
|
47
|
+
raise ImportError("group_contrast needs scipy") from e
|
|
48
|
+
config = config or Config()
|
|
49
|
+
|
|
50
|
+
def label(i):
|
|
51
|
+
o = observations[i]
|
|
52
|
+
return group_of(o) if callable(group_of) else group_of.get(o.name)
|
|
53
|
+
|
|
54
|
+
labels = [label(i) for i in range(len(observations))]
|
|
55
|
+
present = [g for g in dict.fromkeys(labels) if g is not None]
|
|
56
|
+
groups = groups or sorted(present, key=str)
|
|
57
|
+
if len(groups) != 2:
|
|
58
|
+
raise ValueError(f"group_contrast compares exactly two groups; found {groups}. "
|
|
59
|
+
"Give a group_of that yields two labels (others -> None).")
|
|
60
|
+
gA, gB = groups
|
|
61
|
+
nA = sum(1 for x in labels if x == gA)
|
|
62
|
+
nB = sum(1 for x in labels if x == gB)
|
|
63
|
+
if not nA or not nB:
|
|
64
|
+
raise ValueError(f"a group is empty ({gA}: {nA}, {gB}: {nB}).")
|
|
65
|
+
|
|
66
|
+
pats = [p for p in Engine(observations, config).detect() if p.level >= 1]
|
|
67
|
+
rows = []
|
|
68
|
+
for p in pats:
|
|
69
|
+
if p.N < min_count:
|
|
70
|
+
continue
|
|
71
|
+
obs_with = {inst.obs for inst in p.instances}
|
|
72
|
+
a = sum(1 for i in obs_with if labels[i] == gA)
|
|
73
|
+
b = sum(1 for i in obs_with if labels[i] == gB)
|
|
74
|
+
orr, lo, hi = _or_ci(a, nA - a, b, nB - b)
|
|
75
|
+
_, pval = fisher_exact([[a, nA - a], [b, nB - b]])
|
|
76
|
+
rows.append({
|
|
77
|
+
"pattern": str(p), "signature": p.signature(), "N": p.N,
|
|
78
|
+
f"{gA}": f"{a}/{nA}", f"{gB}": f"{b}/{nB}",
|
|
79
|
+
"odds_ratio": round(orr, 2), "ci_low": round(lo, 2),
|
|
80
|
+
"ci_high": round(hi, 2), "p": round(pval, 4),
|
|
81
|
+
"more_common_in": gA if orr > 1 else gB,
|
|
82
|
+
})
|
|
83
|
+
rows.sort(key=lambda r: r["p"])
|
|
84
|
+
return rows
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def contrast_items(rows, top: int = 15):
|
|
88
|
+
"""Turn group_contrast rows into the item list forest_plot expects."""
|
|
89
|
+
return [{"label": r["pattern"][:42], "or": r["odds_ratio"], "lo": r["ci_low"],
|
|
90
|
+
"hi": r["ci_high"], "p": r["p"]} for r in rows[:top]]
|