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 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]]