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/methods.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Machine-generated Methods statement — the settings *and their semantics*.
|
|
3
|
+
=========================================================================
|
|
4
|
+
|
|
5
|
+
The single most transparent thing an open implementation can do is state, in full,
|
|
6
|
+
not only *which* settings were used but *what each one does*. Reporting a value
|
|
7
|
+
("frequent-event exclusion = 1.50") does not make a result reproducible if the
|
|
8
|
+
value's behaviour is hidden — two tools can read the same number and act
|
|
9
|
+
differently (see the note's Section 3.1). So `methods_text()` emits a complete,
|
|
10
|
+
paste-ready Methods paragraph in which every parameter is accompanied by its
|
|
11
|
+
operational definition, generated from the actual `Config` that was run.
|
|
12
|
+
|
|
13
|
+
from tpattern import methods_text
|
|
14
|
+
print(methods_text(config, observations=obs, calibration=result))
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
VERSION = "0.1.0"
|
|
20
|
+
CONCEPT_DOI = "10.5281/zenodo.21397543"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def methods_text(config, *, observations=None, calibration=None,
|
|
24
|
+
cite: bool = True) -> str:
|
|
25
|
+
"""Return a Methods-section description of a tpattern analysis.
|
|
26
|
+
|
|
27
|
+
Every setting is stated with its operational definition, so the parameter
|
|
28
|
+
*semantics* travel with the analysis, not merely their values. Pass the
|
|
29
|
+
`observations` to include the sample size and total observation time, and a
|
|
30
|
+
`CalibrationResult` to describe the surrogate null and error control.
|
|
31
|
+
"""
|
|
32
|
+
s = []
|
|
33
|
+
|
|
34
|
+
# --- provenance ---
|
|
35
|
+
if cite:
|
|
36
|
+
s.append(
|
|
37
|
+
f"T-pattern detection was performed with tpattern (v{VERSION}; "
|
|
38
|
+
f"Callaway, 2026; https://doi.org/{CONCEPT_DOI}), an open-source "
|
|
39
|
+
f"implementation of Magnusson's (2000) T-pattern algorithm.")
|
|
40
|
+
else:
|
|
41
|
+
s.append("T-pattern detection was performed with tpattern, an open-source "
|
|
42
|
+
"implementation of Magnusson's (2000) T-pattern algorithm.")
|
|
43
|
+
|
|
44
|
+
# --- data ---
|
|
45
|
+
if observations is not None:
|
|
46
|
+
n_obs = len(observations)
|
|
47
|
+
n_ev = sum(len(o.events) for o in observations)
|
|
48
|
+
T = sum(max(o.end - o.start, 1) for o in observations)
|
|
49
|
+
s.append(f"The sample comprised {n_obs} observations containing {n_ev} "
|
|
50
|
+
f"event occurrences over {T} time units of total observation window.")
|
|
51
|
+
|
|
52
|
+
# --- core detection ---
|
|
53
|
+
s.append(
|
|
54
|
+
f"For each ordered pair of event types (A, B), the critical interval was the "
|
|
55
|
+
f"largest time window in which B followed A more often than expected under the "
|
|
56
|
+
f"NX/T baseline, where the baseline is the probability of at least one B in a "
|
|
57
|
+
f"window of that width given B's overall rate; significance was the upper-tail "
|
|
58
|
+
f"binomial probability of the observed number of A-to-B matches, thresholded at "
|
|
59
|
+
f"α = {config.alpha}. Occurrences were matched one to one by greedy interval "
|
|
60
|
+
f"scheduling, and each event occurrence could fill at most one role within a "
|
|
61
|
+
f"pattern (distinct-token rule). Patterns were built bottom-up into a binary "
|
|
62
|
+
f"hierarchy and were required to recur at least {config.min_occurrence} times.")
|
|
63
|
+
|
|
64
|
+
# --- frequent-event exclusion (the setting whose semantics matter most) ---
|
|
65
|
+
if getattr(config, "exclude_events", None) is not None:
|
|
66
|
+
ex = config.exclude_events
|
|
67
|
+
if ex:
|
|
68
|
+
s.append(f"The following event types were excluded from pattern "
|
|
69
|
+
f"construction (retained for baseline counts only): "
|
|
70
|
+
f"{', '.join(ex)}.")
|
|
71
|
+
else:
|
|
72
|
+
s.append("No event types were excluded from pattern construction.")
|
|
73
|
+
elif getattr(config, "freq_exclude", None) is not None:
|
|
74
|
+
s.append(
|
|
75
|
+
f"Event types whose mean number of occurrences per observation exceeded "
|
|
76
|
+
f"{config.freq_exclude} were excluded from building patterns (the "
|
|
77
|
+
f"frequent-event exclusion); they were retained in the univariate baseline "
|
|
78
|
+
f"counts. Note that this threshold is defined on mean occurrences per "
|
|
79
|
+
f"observation.")
|
|
80
|
+
else:
|
|
81
|
+
s.append("No frequent-event exclusion was applied; all event types were "
|
|
82
|
+
"eligible to build patterns.")
|
|
83
|
+
|
|
84
|
+
# --- concurrency handling ---
|
|
85
|
+
if getattr(config, "min_lag", 0) and config.min_lag >= 1:
|
|
86
|
+
s.append(
|
|
87
|
+
f"A genuine temporal gap of at least {config.min_lag} time unit(s) was "
|
|
88
|
+
f"required between two linked events (min_lag = {config.min_lag}); events "
|
|
89
|
+
f"sharing a timestamp were therefore treated as co-occurrence and excluded "
|
|
90
|
+
f"from directional pattern detection.")
|
|
91
|
+
else:
|
|
92
|
+
s.append(
|
|
93
|
+
"Events sharing a timestamp were permitted to form directional links "
|
|
94
|
+
"(min_lag = 0); where timestamps are coarsely resolved this treats "
|
|
95
|
+
"co-timed events as ordered, and such links should be read as "
|
|
96
|
+
"co-occurrence rather than sequence.")
|
|
97
|
+
|
|
98
|
+
# --- reduction / pruning options ---
|
|
99
|
+
if getattr(config, "completeness", True):
|
|
100
|
+
s.append("Patterns whose every occurrence was contained within a longer "
|
|
101
|
+
"detected pattern were removed (completeness competition).")
|
|
102
|
+
if getattr(config, "lumping_factor", None) is not None:
|
|
103
|
+
s.append(f"A lumping factor of {config.lumping_factor} was applied: when a "
|
|
104
|
+
f"pair (A, B) formed with a forward conditional probability above this "
|
|
105
|
+
f"value, the dependent terminal was removed from the remaining search.")
|
|
106
|
+
if getattr(config, "min_samples_frac", None) is not None:
|
|
107
|
+
s.append(f"Patterns were additionally required to occur in at least "
|
|
108
|
+
f"{config.min_samples_frac:.0%} of observations.")
|
|
109
|
+
|
|
110
|
+
# --- calibration / significance ---
|
|
111
|
+
if calibration is not None:
|
|
112
|
+
null_name = {"profile": "profile-preserving", "rotation": "rotation",
|
|
113
|
+
"shuffle": "shuffling"}.get(calibration.null, calibration.null)
|
|
114
|
+
s.append(
|
|
115
|
+
f"Every detected pattern was then tested against a {null_name} surrogate "
|
|
116
|
+
f"null: {calibration.B} surrogate datasets were generated and run through "
|
|
117
|
+
f"the identical detection pipeline, giving a Monte-Carlo empirical p-value "
|
|
118
|
+
f"per pattern (so the test is automatically corrected for the selection "
|
|
119
|
+
f"over candidate intervals). Because the empirical p-value cannot fall "
|
|
120
|
+
f"below 1/({calibration.B}+1), family-wise claims require the surrogate "
|
|
121
|
+
f"count to be raised accordingly. Multiplicity was controlled by the "
|
|
122
|
+
f"Benjamini-Hochberg false-discovery rate (q ≤ {calibration.q_target}) "
|
|
123
|
+
f"and, for confirmatory claims, the Holm family-wise error rate "
|
|
124
|
+
f"(α = {calibration.alpha}), each stratified by pattern level.")
|
|
125
|
+
|
|
126
|
+
if cite:
|
|
127
|
+
s.append(
|
|
128
|
+
f"The analysis is fully reproducible from the archived software "
|
|
129
|
+
f"(https://doi.org/{CONCEPT_DOI}); the settings above, including each "
|
|
130
|
+
f"parameter's operational definition, are emitted by the software itself.")
|
|
131
|
+
|
|
132
|
+
return " ".join(s)
|
tpattern/pattern.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The T-pattern data structure.
|
|
3
|
+
=============================
|
|
4
|
+
|
|
5
|
+
A T-pattern is a **binary tree**. This mirrors how Magnusson (2000) builds
|
|
6
|
+
patterns: never more than two things are joined at once. Bigger patterns are
|
|
7
|
+
made by joining a pattern to another pattern (or single event), so the tree
|
|
8
|
+
grows one critical-interval relationship at a time.
|
|
9
|
+
|
|
10
|
+
Level 0 (a "terminal"): a single event type, e.g. Challenge_Pressure
|
|
11
|
+
Level 1: (A B) two events linked by a critical interval
|
|
12
|
+
Level 2: (A (B C)) an event linked to a level-1 pattern
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
Reading order is always left-to-right in time: in `(A B)`, every occurrence has
|
|
16
|
+
an A that is *followed by* a B within the critical interval discovered for that
|
|
17
|
+
link.
|
|
18
|
+
|
|
19
|
+
Occurrences
|
|
20
|
+
-----------
|
|
21
|
+
Each pattern carries a concrete list of the times it actually happened. One
|
|
22
|
+
occurrence is an :class:`Instance`: which observation it is in, and the
|
|
23
|
+
[t_start, t_end] span it covers (t_start = time of the first/left-most event,
|
|
24
|
+
t_end = time of the last/right-most event). The *number of occurrences* N is
|
|
25
|
+
simply ``len(pattern.instances)`` — this is the N reported in the paper.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Instance:
|
|
35
|
+
"""One realised occurrence of a pattern within one observation.
|
|
36
|
+
|
|
37
|
+
`tokens` holds the identities of the *distinct raw event occurrences* that
|
|
38
|
+
make up this instance. THEME requires every pattern occurrence to be built
|
|
39
|
+
from distinct events — one event may not fill two roles — so when two
|
|
40
|
+
sub-patterns are joined their token sets must be disjoint. Terminals carry a
|
|
41
|
+
single token (the event's unique id).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
obs: int # index of the observation in the sample
|
|
45
|
+
start: int # time (ms) of the left-most event
|
|
46
|
+
end: int # time (ms) of the right-most event
|
|
47
|
+
tokens: frozenset = frozenset()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Pattern:
|
|
52
|
+
"""A node in the T-pattern tree.
|
|
53
|
+
|
|
54
|
+
A *terminal* has ``left is None and right is None`` and an ``event`` label.
|
|
55
|
+
A *composite* has ``left`` and ``right`` sub-patterns joined by the critical
|
|
56
|
+
interval ``ci = (d1, d2)`` (only meaningful for composites).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
event: str | None = None # set only for terminals
|
|
60
|
+
left: "Pattern | None" = None
|
|
61
|
+
right: "Pattern | None" = None
|
|
62
|
+
ci: tuple[int, int] | None = None # (d1, d2) critical interval, ms
|
|
63
|
+
p_value: float | None = None # significance of the link
|
|
64
|
+
instances: list[Instance] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------ counts
|
|
67
|
+
@property
|
|
68
|
+
def N(self) -> int:
|
|
69
|
+
"""Number of occurrences across the whole sample."""
|
|
70
|
+
return len(self.instances)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_terminal(self) -> bool:
|
|
74
|
+
return self.left is None and self.right is None
|
|
75
|
+
|
|
76
|
+
# ----------------------------------------------------------- tree geometry
|
|
77
|
+
def leaves(self) -> list[str]:
|
|
78
|
+
"""Event types in left-to-right (temporal) order, with repeats kept."""
|
|
79
|
+
if self.is_terminal:
|
|
80
|
+
return [self.event] # type: ignore[list-item]
|
|
81
|
+
return self.left.leaves() + self.right.leaves()
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def length(self) -> int:
|
|
85
|
+
"""Pattern length = number of terminal events (THEME 'length')."""
|
|
86
|
+
return len(self.leaves())
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def level(self) -> int:
|
|
90
|
+
"""Hierarchical level = tree depth. Terminals are level 0."""
|
|
91
|
+
if self.is_terminal:
|
|
92
|
+
return 0
|
|
93
|
+
return 1 + max(self.left.level, self.right.level)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def has_loop(self) -> bool:
|
|
97
|
+
"""A 'loop' is a recurrence: the same event type appears twice or more.
|
|
98
|
+
|
|
99
|
+
THEME flags these (hasloop = 1) as recycling structures — e.g. the
|
|
100
|
+
interception-mediated loops that characterise saved-shot sequences.
|
|
101
|
+
"""
|
|
102
|
+
seen = self.leaves()
|
|
103
|
+
return len(set(seen)) < len(seen)
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------- identity/str
|
|
106
|
+
def signature(self) -> str:
|
|
107
|
+
"""A canonical string that uniquely identifies the tree's *shape*.
|
|
108
|
+
|
|
109
|
+
Two patterns with the same signature are the same pattern (same events,
|
|
110
|
+
same bracketing). Used to deduplicate during detection. Critical-interval
|
|
111
|
+
widths are deliberately excluded so that the same structure discovered
|
|
112
|
+
via slightly different intervals is treated as one pattern.
|
|
113
|
+
"""
|
|
114
|
+
if self.is_terminal:
|
|
115
|
+
return self.event # type: ignore[return-value]
|
|
116
|
+
return f"({self.left.signature()} {self.right.signature()})"
|
|
117
|
+
|
|
118
|
+
def __str__(self) -> str:
|
|
119
|
+
"""Human-readable rendering, e.g. cross → (challenge → shot_goal)."""
|
|
120
|
+
if self.is_terminal:
|
|
121
|
+
return self.event # type: ignore[return-value]
|
|
122
|
+
l, r = str(self.left), str(self.right)
|
|
123
|
+
if not self.left.is_terminal:
|
|
124
|
+
l = f"({l})"
|
|
125
|
+
if not self.right.is_terminal:
|
|
126
|
+
r = f"({r})"
|
|
127
|
+
return f"{l} → {r}"
|
|
128
|
+
|
|
129
|
+
__repr__ = __str__
|
tpattern/randomise.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Randomisation null — the Type-I-error control THEME can't easily give you.
|
|
3
|
+
==========================================================================
|
|
4
|
+
|
|
5
|
+
The analytic critical-interval test assumes that, under the null, the second
|
|
6
|
+
event is scattered independently and uniformly. Real event streams violate that
|
|
7
|
+
(events cluster, densities vary within a possession). So a pattern can clear the
|
|
8
|
+
analytic 0.005 bar and still be an artefact of that clustering rather than a
|
|
9
|
+
genuine temporal relationship.
|
|
10
|
+
|
|
11
|
+
The fix is an *empirical* null: destroy the real cross-event timing while keeping
|
|
12
|
+
each event type's own frequency and internal spacing, re-run the entire
|
|
13
|
+
detection, and see which patterns still show up. A pattern that keeps appearing
|
|
14
|
+
in randomised data — where by construction there is no genuine structure — is not
|
|
15
|
+
trustworthy. This is exactly what makes the result defensible to a sceptical
|
|
16
|
+
reviewer, and it is the honest way to decide which borderline patterns to keep.
|
|
17
|
+
|
|
18
|
+
Two surrogate schemes (THEME's "Shuffling and Rotation"), 10 runs each:
|
|
19
|
+
|
|
20
|
+
* Rotation — within each observation, cyclically shift every event type's
|
|
21
|
+
time-line by its own random offset (mod window length). This keeps
|
|
22
|
+
each type's count and internal inter-event spacing intact but
|
|
23
|
+
scrambles the *relative* timing between types. It is the stricter,
|
|
24
|
+
more structure-preserving null.
|
|
25
|
+
* Shuffling — within each observation, keep the set of event *times* but randomly
|
|
26
|
+
permute which event *type* sits at each time. This preserves the
|
|
27
|
+
overall event density and timing but destroys type-specific
|
|
28
|
+
temporal relationships.
|
|
29
|
+
|
|
30
|
+
For each real pattern we report an empirical p-value: the fraction of surrogate
|
|
31
|
+
runs in which the *same* pattern (identical signature) recurs with an occurrence
|
|
32
|
+
count at least as high as observed. Empirical p = 0 means the pattern never
|
|
33
|
+
arose by chance in any surrogate.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import random
|
|
39
|
+
from collections import defaultdict
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
|
|
42
|
+
from .detect import Config, Engine
|
|
43
|
+
from .io import Observation
|
|
44
|
+
from .pattern import Pattern
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def rotate(obs: Observation, rng: random.Random) -> Observation:
|
|
48
|
+
"""Rotation surrogate for one observation (per-type cyclic time shift)."""
|
|
49
|
+
T = max(obs.T, 1)
|
|
50
|
+
by_type: dict[str, list[int]] = defaultdict(list)
|
|
51
|
+
for t, ev in obs.events:
|
|
52
|
+
by_type[ev].append(t)
|
|
53
|
+
new_events: list[tuple[int, str]] = []
|
|
54
|
+
for ev, times in by_type.items():
|
|
55
|
+
offset = rng.randrange(T)
|
|
56
|
+
for t in times:
|
|
57
|
+
nt = obs.start + ((t - obs.start + offset) % T)
|
|
58
|
+
new_events.append((nt, ev))
|
|
59
|
+
new_events.sort()
|
|
60
|
+
return Observation(name=obs.name, start=obs.start, end=obs.end, events=new_events)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def shuffle(obs: Observation, rng: random.Random) -> Observation:
|
|
64
|
+
"""Shuffling surrogate for one observation (permute type labels over times)."""
|
|
65
|
+
times = [t for t, _ in obs.events]
|
|
66
|
+
types = [ev for _, ev in obs.events]
|
|
67
|
+
rng.shuffle(types)
|
|
68
|
+
new_events = sorted(zip(times, types))
|
|
69
|
+
return Observation(name=obs.name, start=obs.start, end=obs.end, events=new_events)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_profiles(observations) -> dict:
|
|
73
|
+
"""Pooled within-sequence position (u = (t-start)/T) for each event type.
|
|
74
|
+
|
|
75
|
+
This is each type's empirical *marginal temporal intensity* — where in a
|
|
76
|
+
sequence it tends to occur (shots late ~0.9, passes early ~0.3). It is the
|
|
77
|
+
structure the profile-preserving null keeps.
|
|
78
|
+
"""
|
|
79
|
+
import numpy as np
|
|
80
|
+
pos: dict[str, list[float]] = defaultdict(list)
|
|
81
|
+
for o in observations:
|
|
82
|
+
span = max(o.end - o.start, 1)
|
|
83
|
+
for t, ev in o.events:
|
|
84
|
+
# clamp to [0, 1] so a surrogate can never be placed outside the
|
|
85
|
+
# observation window even if an event's time falls on/just past a border.
|
|
86
|
+
pos[ev].append(min(1.0, max(0.0, (t - o.start) / span)))
|
|
87
|
+
return {ev: np.asarray(v) for ev, v in pos.items()}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def profile_surrogate(observations, profiles, rng) -> list:
|
|
91
|
+
"""Profile-preserving surrogate (null N2).
|
|
92
|
+
|
|
93
|
+
For each observation and each event type, redraw that type's event times from
|
|
94
|
+
its *own* pooled marginal position profile (with replacement), keeping the
|
|
95
|
+
per-observation count. This preserves (i) each type's count per sequence and
|
|
96
|
+
(ii) each type's marginal temporal profile, while destroying any *cross-type*
|
|
97
|
+
temporal coupling. Significance against this null therefore isolates genuine
|
|
98
|
+
coordination between event types — beyond the fact that each type has its own
|
|
99
|
+
characteristic timing.
|
|
100
|
+
|
|
101
|
+
`rng` is a numpy Generator (fast vectorised sampling).
|
|
102
|
+
"""
|
|
103
|
+
out = []
|
|
104
|
+
for o in observations:
|
|
105
|
+
span = max(o.end - o.start, 1)
|
|
106
|
+
counts: dict[str, int] = defaultdict(int)
|
|
107
|
+
for _, ev in o.events:
|
|
108
|
+
counts[ev] += 1
|
|
109
|
+
events: list[tuple[int, str]] = []
|
|
110
|
+
for ev, c in counts.items():
|
|
111
|
+
us = rng.choice(profiles[ev], size=c, replace=True)
|
|
112
|
+
for u in us:
|
|
113
|
+
events.append((int(round(o.start + float(u) * span)), ev))
|
|
114
|
+
events.sort()
|
|
115
|
+
out.append(Observation(name=o.name, start=o.start, end=o.end, events=events))
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def randomise_sample(observations, method: str, rng: random.Random):
|
|
120
|
+
fn = rotate if method == "rotation" else shuffle
|
|
121
|
+
return [fn(o, rng) for o in observations]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class NullResult:
|
|
126
|
+
"""Summary of the randomisation null for one detection."""
|
|
127
|
+
|
|
128
|
+
real: list[Pattern] # patterns detected in real data
|
|
129
|
+
runs: dict[str, list[list[Pattern]]] # method -> list of per-run pattern lists
|
|
130
|
+
empirical_p: dict[str, float] # real-pattern signature -> empirical p
|
|
131
|
+
n_runs_total: int
|
|
132
|
+
|
|
133
|
+
def level_counts(self):
|
|
134
|
+
"""Mean number of composite patterns per level: real vs surrogate."""
|
|
135
|
+
def by_level(pats):
|
|
136
|
+
c = defaultdict(int)
|
|
137
|
+
for p in pats:
|
|
138
|
+
if p.level >= 1:
|
|
139
|
+
c[p.level] += 1
|
|
140
|
+
return c
|
|
141
|
+
|
|
142
|
+
real_c = by_level(self.real)
|
|
143
|
+
rand_lists = [p for runs in self.runs.values() for p in runs]
|
|
144
|
+
rand_c = defaultdict(list)
|
|
145
|
+
for pats in rand_lists:
|
|
146
|
+
c = by_level(pats)
|
|
147
|
+
lv = set(c) | set(real_c)
|
|
148
|
+
for k in lv:
|
|
149
|
+
rand_c[k].append(c.get(k, 0))
|
|
150
|
+
levels = sorted(set(real_c) | set(rand_c))
|
|
151
|
+
return [
|
|
152
|
+
(lv, real_c.get(lv, 0),
|
|
153
|
+
sum(rand_c.get(lv, [])) / max(len(rand_c.get(lv, [])), 1))
|
|
154
|
+
for lv in levels
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
def survivors(self, threshold: float = 0.0) -> list[Pattern]:
|
|
158
|
+
"""Real composite patterns with empirical p <= threshold.
|
|
159
|
+
|
|
160
|
+
threshold = 0.0 keeps only patterns that never recurred in any surrogate
|
|
161
|
+
at equal-or-greater strength — the strictest, cleanest filter given a
|
|
162
|
+
finite number of runs.
|
|
163
|
+
"""
|
|
164
|
+
return [p for p in self.real
|
|
165
|
+
if p.level >= 1 and self.empirical_p.get(p.signature(), 1.0) <= threshold]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def run_null(observations, config: Config | None = None,
|
|
169
|
+
n_runs: int = 10, seed: int = 12345) -> NullResult:
|
|
170
|
+
"""Detect on real data, then on 10 rotation + 10 shuffling surrogates."""
|
|
171
|
+
config = config or Config()
|
|
172
|
+
|
|
173
|
+
real = Engine(observations, config).detect()
|
|
174
|
+
real_index: dict[str, int] = {}
|
|
175
|
+
for p in real:
|
|
176
|
+
if p.level >= 1:
|
|
177
|
+
real_index[p.signature()] = p.N
|
|
178
|
+
|
|
179
|
+
runs: dict[str, list[list[Pattern]]] = {"rotation": [], "shuffling": []}
|
|
180
|
+
# count, per real signature, how many surrogate runs match-or-beat it
|
|
181
|
+
beat: dict[str, int] = defaultdict(int)
|
|
182
|
+
|
|
183
|
+
# fixed per-method offset — NOT hash(method), whose value is salted per Python
|
|
184
|
+
# process (PYTHONHASHSEED), which would make identical `seed` arguments produce
|
|
185
|
+
# different surrogates across runs despite the reproducibility the signature promises.
|
|
186
|
+
method_offset = {"rotation": 0, "shuffling": 1}
|
|
187
|
+
for method in ("rotation", "shuffling"):
|
|
188
|
+
for r in range(n_runs):
|
|
189
|
+
rng = random.Random((method_offset[method] * 2246822519
|
|
190
|
+
^ (r * 2654435761) ^ seed) & 0xFFFFFFFF)
|
|
191
|
+
surrogate = randomise_sample(observations, method, rng)
|
|
192
|
+
pats = Engine(surrogate, config).detect()
|
|
193
|
+
runs[method].append(pats)
|
|
194
|
+
rand_best: dict[str, int] = {}
|
|
195
|
+
for p in pats:
|
|
196
|
+
if p.level >= 1:
|
|
197
|
+
sig = p.signature()
|
|
198
|
+
if p.N > rand_best.get(sig, 0):
|
|
199
|
+
rand_best[sig] = p.N
|
|
200
|
+
for sig, n_real in real_index.items():
|
|
201
|
+
if rand_best.get(sig, 0) >= n_real:
|
|
202
|
+
beat[sig] += 1
|
|
203
|
+
|
|
204
|
+
total = n_runs * 2
|
|
205
|
+
empirical_p = {sig: beat.get(sig, 0) / total for sig in real_index}
|
|
206
|
+
return NullResult(real=real, runs=runs, empirical_p=empirical_p, n_runs_total=total)
|
tpattern/report.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reporting — the output layer THEME does poorly.
|
|
3
|
+
|
|
4
|
+
Three things:
|
|
5
|
+
|
|
6
|
+
1. `patterns_table` — a tidy results table for detected patterns: the pattern
|
|
7
|
+
string, N, length, level, loop flag, critical interval, and (when a
|
|
8
|
+
calibration is supplied) the surrogate empirical p with **repeated-testing
|
|
9
|
+
correction** (Benjamini–Hochberg q for screening, Holm/FWER keep for
|
|
10
|
+
confirmation). Writes CSV and returns the rows.
|
|
11
|
+
|
|
12
|
+
2. `forest_plot` — effect sizes with confidence intervals (odds ratios from the
|
|
13
|
+
group/outcome contrasts) as a forest plot, the standard way to show
|
|
14
|
+
which contrasts matter and how uncertain they are.
|
|
15
|
+
|
|
16
|
+
3. `report` — one call that writes the table, the top-pattern dendrograms and (if
|
|
17
|
+
given) the forest plot into an output folder, so a detection run yields a
|
|
18
|
+
ready-to-read report.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import csv
|
|
24
|
+
import math
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
import matplotlib
|
|
28
|
+
matplotlib.use("Agg")
|
|
29
|
+
import matplotlib.pyplot as plt
|
|
30
|
+
from matplotlib.transforms import blended_transform_factory
|
|
31
|
+
|
|
32
|
+
from .pattern import Pattern
|
|
33
|
+
from .significance import CalibrationResult, Calibrated
|
|
34
|
+
from .viz import patterns_overview
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --------------------------------------------------------------------- table
|
|
38
|
+
def _fmt_ci(ci, unit):
|
|
39
|
+
if not ci:
|
|
40
|
+
return ""
|
|
41
|
+
return f"[{ci[0]},{ci[1]}]{unit}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def interpret(pattern, N, fdr_q, *, q_target=0.05, ci_unit=""):
|
|
45
|
+
"""A plain-English reading of one calibrated pattern — what it is, whether it
|
|
46
|
+
is trustworthy, and its timing. Used as the results table's `interpretation`
|
|
47
|
+
column so a non-specialist can read the output directly."""
|
|
48
|
+
if fdr_q <= q_target:
|
|
49
|
+
verdict, meaning = (f"Robust (survives FDR, q={fdr_q:.3g})",
|
|
50
|
+
"a genuine recurring sequence")
|
|
51
|
+
elif fdr_q <= 2 * q_target:
|
|
52
|
+
verdict, meaning = (f"Borderline (q={fdr_q:.3g})",
|
|
53
|
+
"near the significance threshold — treat with caution and "
|
|
54
|
+
"re-check with more surrogates")
|
|
55
|
+
else:
|
|
56
|
+
verdict, meaning = (f"Not significant after correction (q={fdr_q:.3g})",
|
|
57
|
+
"consistent with chance")
|
|
58
|
+
timing = ""
|
|
59
|
+
if pattern.ci and not pattern.is_terminal:
|
|
60
|
+
d1, d2 = pattern.ci
|
|
61
|
+
timing = (f"; its components are linked within {d1}–{d2}{ci_unit}"
|
|
62
|
+
if d1 != d2 else f"; components co-occur (0{ci_unit} apart)")
|
|
63
|
+
loop = " (a recycling loop: a repeated event type)" if pattern.has_loop else ""
|
|
64
|
+
plural = "occurrence" if N == 1 else "occurrences"
|
|
65
|
+
return f"{verdict}: {meaning}, {N} {plural}{timing}{loop}."
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def patterns_table(source, outfile: str | None = None, ci_unit: str = "",
|
|
69
|
+
sort: str = "auto"):
|
|
70
|
+
"""Build a results table from a list of `Pattern` or a `CalibrationResult`.
|
|
71
|
+
|
|
72
|
+
Returns a list of row dicts. If `outfile` is given, also writes CSV.
|
|
73
|
+
When given a CalibrationResult, includes p_emp, fdr_q, fwer_keep and sorts by
|
|
74
|
+
p_emp; otherwise sorts by N (descending).
|
|
75
|
+
"""
|
|
76
|
+
calibrated = isinstance(source, CalibrationResult)
|
|
77
|
+
items = source.real if calibrated else [p for p in source if p.level >= 1]
|
|
78
|
+
|
|
79
|
+
rows = []
|
|
80
|
+
for it in items:
|
|
81
|
+
p = it.pattern if calibrated else it
|
|
82
|
+
row = {
|
|
83
|
+
"pattern": str(p),
|
|
84
|
+
"signature": p.signature(),
|
|
85
|
+
"N": p.N,
|
|
86
|
+
"length": p.length,
|
|
87
|
+
"level": p.level,
|
|
88
|
+
"loop": int(p.has_loop),
|
|
89
|
+
"critical_interval": _fmt_ci(p.ci, ci_unit),
|
|
90
|
+
}
|
|
91
|
+
if calibrated:
|
|
92
|
+
row["p_emp"] = round(it.p_emp, 4)
|
|
93
|
+
row["fdr_q"] = round(it.fdr_q, 4)
|
|
94
|
+
row["fwer_keep"] = int(it.fwer_keep)
|
|
95
|
+
row["analytic_strength"] = round(it.strength, 2) # -log10(analytic p); comparison only
|
|
96
|
+
row["interpretation"] = interpret(p, it.N, it.fdr_q, ci_unit=ci_unit)
|
|
97
|
+
rows.append(row)
|
|
98
|
+
|
|
99
|
+
key = sort
|
|
100
|
+
if sort == "auto":
|
|
101
|
+
key = "p_emp" if calibrated else "N"
|
|
102
|
+
if key == "N":
|
|
103
|
+
rows.sort(key=lambda r: (-r["N"], r["level"]))
|
|
104
|
+
elif key in ("p_emp", "fdr_q") and calibrated:
|
|
105
|
+
rows.sort(key=lambda r: (r[key], -r["N"]))
|
|
106
|
+
|
|
107
|
+
if outfile:
|
|
108
|
+
with open(outfile, "w", newline="") as fh:
|
|
109
|
+
w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()) if rows else ["pattern"])
|
|
110
|
+
w.writeheader()
|
|
111
|
+
w.writerows(rows)
|
|
112
|
+
return rows
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --------------------------------------------------------------- forest plot
|
|
116
|
+
def forest_plot(items, outfile: str, title: str = "Effect sizes (odds ratios)",
|
|
117
|
+
xlabel: str = "odds ratio (log scale)"):
|
|
118
|
+
"""Forest plot of odds ratios with 95% CIs.
|
|
119
|
+
|
|
120
|
+
`items` is a list of dicts, each: {label, or, lo, hi, p (optional), n (opt)}.
|
|
121
|
+
A reference line is drawn at OR = 1. Points whose CI excludes 1 are coloured.
|
|
122
|
+
"""
|
|
123
|
+
items = list(items)
|
|
124
|
+
if not items:
|
|
125
|
+
return
|
|
126
|
+
n = len(items)
|
|
127
|
+
fig, ax = plt.subplots(figsize=(9, 0.6 * n + 1.4))
|
|
128
|
+
ys = list(range(n, 0, -1)) # top-to-bottom in given order
|
|
129
|
+
|
|
130
|
+
# fix x-range up front so label anchoring is stable on the log scale
|
|
131
|
+
los = [it["lo"] for it in items]; his = [it["hi"] for it in items]
|
|
132
|
+
ax.set_xscale("log")
|
|
133
|
+
ax.set_xlim(min(los) * 0.7, max(his) * 1.35)
|
|
134
|
+
ax.set_ylim(0.3, n + 0.9)
|
|
135
|
+
# label in axes-x (fixed left/right), data-y
|
|
136
|
+
tx = blended_transform_factory(ax.transAxes, ax.transData)
|
|
137
|
+
|
|
138
|
+
for y, it in zip(ys, items):
|
|
139
|
+
lo, hi, orr = it["lo"], it["hi"], it["or"]
|
|
140
|
+
sig = lo > 1 or hi < 1
|
|
141
|
+
colour = "#c0392b" if sig else "#7f8c8d"
|
|
142
|
+
ax.plot([lo, hi], [y, y], color=colour, lw=1.8, solid_capstyle="round")
|
|
143
|
+
ax.plot([orr], [y], "o", color=colour, ms=7)
|
|
144
|
+
lbl = it["label"]
|
|
145
|
+
if it.get("p") is not None:
|
|
146
|
+
lbl += f" (p={it['p']:.3g})"
|
|
147
|
+
ax.text(0.005, y + 0.18, lbl, transform=tx, ha="left", va="bottom", fontsize=8)
|
|
148
|
+
ax.text(0.995, y, f"{orr:.2f} [{lo:.2f}, {hi:.2f}]", transform=tx,
|
|
149
|
+
ha="right", va="center", fontsize=7.5, color="#555")
|
|
150
|
+
|
|
151
|
+
ax.axvline(1.0, color="#2c3e50", lw=1.0, ls="--")
|
|
152
|
+
ax.set_yticks([])
|
|
153
|
+
ax.set_xlabel(xlabel)
|
|
154
|
+
ax.set_title(title, fontsize=11)
|
|
155
|
+
for s in ("top", "right", "left"):
|
|
156
|
+
ax.spines[s].set_visible(False)
|
|
157
|
+
plt.tight_layout(); plt.savefig(outfile, dpi=150); plt.close()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------- report
|
|
161
|
+
def report(source, outdir: str, *, ci_unit: str = "", title: str = "T-pattern report",
|
|
162
|
+
effects=None, max_dendrograms: int = 8):
|
|
163
|
+
"""Write a full report to `outdir`: table (CSV), top-pattern dendrograms and,
|
|
164
|
+
if `effects` given, a forest plot. Returns paths written."""
|
|
165
|
+
os.makedirs(outdir, exist_ok=True)
|
|
166
|
+
calibrated = isinstance(source, CalibrationResult)
|
|
167
|
+
patterns = [c.pattern for c in source.real] if calibrated else \
|
|
168
|
+
[p for p in source if p.level >= 1]
|
|
169
|
+
|
|
170
|
+
written = {}
|
|
171
|
+
written["table"] = os.path.join(outdir, "patterns_table.csv")
|
|
172
|
+
rows = patterns_table(source, written["table"], ci_unit=ci_unit)
|
|
173
|
+
|
|
174
|
+
if patterns:
|
|
175
|
+
written["dendrograms"] = os.path.join(outdir, "patterns_overview.png")
|
|
176
|
+
patterns_overview(patterns, written["dendrograms"],
|
|
177
|
+
max_rows=max_dendrograms, ci_unit=ci_unit)
|
|
178
|
+
|
|
179
|
+
if effects:
|
|
180
|
+
written["forest"] = os.path.join(outdir, "effect_sizes.png")
|
|
181
|
+
forest_plot(effects, written["forest"])
|
|
182
|
+
|
|
183
|
+
# short text summary
|
|
184
|
+
written["summary"] = os.path.join(outdir, "SUMMARY.txt")
|
|
185
|
+
with open(written["summary"], "w") as fh:
|
|
186
|
+
fh.write(f"{title}\n{'=' * len(title)}\n\n")
|
|
187
|
+
fh.write(f"patterns (level >= 1): {len(patterns)}\n")
|
|
188
|
+
if calibrated:
|
|
189
|
+
kept_fdr = len(source.kept('fdr'))
|
|
190
|
+
kept_fwer = len(source.kept('fwer'))
|
|
191
|
+
fh.write(f"null: {source.null} B={source.B} "
|
|
192
|
+
f"alpha={source.alpha} q_target={source.q_target}\n")
|
|
193
|
+
fh.write(f"kept (FDR q<={source.q_target}): {kept_fdr}\n")
|
|
194
|
+
fh.write(f"kept (FWER Holm): {kept_fwer}\n")
|
|
195
|
+
fh.write("\ntop patterns:\n")
|
|
196
|
+
for r in rows[:15]:
|
|
197
|
+
line = f" N={r['N']:>3} L{r['level']} {r['pattern']}"
|
|
198
|
+
if calibrated:
|
|
199
|
+
line += f" p_emp={r['p_emp']} q={r['fdr_q']}"
|
|
200
|
+
fh.write(line + "\n")
|
|
201
|
+
return written
|