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/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
@@ -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
+ }