tpattern 0.1.2__tar.gz → 0.1.5__tar.gz
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-0.1.2/tpattern.egg-info → tpattern-0.1.5}/PKG-INFO +2 -3
- {tpattern-0.1.2 → tpattern-0.1.5}/pyproject.toml +4 -4
- {tpattern-0.1.2 → tpattern-0.1.5}/tests/test_dedup.py +14 -0
- tpattern-0.1.5/tests/test_null_compare.py +64 -0
- tpattern-0.1.5/tests/test_support_reporting.py +84 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern/__init__.py +6 -5
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern/advisor.py +33 -2
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern/detect.py +49 -4
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern/methods.py +29 -1
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern/report.py +15 -1
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern/significance.py +121 -1
- tpattern-0.1.5/tpattern/viz.py +274 -0
- {tpattern-0.1.2 → tpattern-0.1.5/tpattern.egg-info}/PKG-INFO +2 -3
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern.egg-info/SOURCES.txt +2 -0
- tpattern-0.1.2/tpattern/viz.py +0 -107
- {tpattern-0.1.2 → tpattern-0.1.5}/LICENSE +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/README.md +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/setup.cfg +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tests/test_calibrated_pins.py +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tests/test_contrast.py +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tests/test_guided.py +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tests/test_reproduction.py +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tests/test_synthetic.py +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern/ci.py +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern/contrast.py +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern/diagnostics.py +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern/guided.py +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern/io.py +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern/pattern.py +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern/randomise.py +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern/synthetic.py +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern.egg-info/dependency_links.txt +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern.egg-info/requires.txt +0 -0
- {tpattern-0.1.2 → tpattern-0.1.5}/tpattern.egg-info/top_level.txt +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tpattern
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Open, auditable temporal & sequential pattern analysis (T-patterns) for sport and behaviour
|
|
5
5
|
Author-email: Andrew Callaway <acallaway@bournemouth.ac.uk>
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/ajcallaway/TPattern
|
|
8
8
|
Project-URL: Repository, https://github.com/ajcallaway/TPattern
|
|
9
9
|
Project-URL: Archive (DOI), https://doi.org/10.5281/zenodo.21397543
|
|
@@ -15,7 +15,6 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
19
18
|
Classifier: Operating System :: OS Independent
|
|
20
19
|
Classifier: Intended Audience :: Science/Research
|
|
21
20
|
Classifier: Topic :: Scientific/Engineering
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools>=
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tpattern"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
8
8
|
description = "Open, auditable temporal & sequential pattern analysis (T-patterns) for sport and behaviour"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
|
-
license =
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
12
13
|
authors = [
|
|
13
14
|
{ name = "Andrew Callaway", email = "acallaway@bournemouth.ac.uk" },
|
|
14
15
|
]
|
|
@@ -20,7 +21,6 @@ classifiers = [
|
|
|
20
21
|
"Programming Language :: Python :: 3.10",
|
|
21
22
|
"Programming Language :: Python :: 3.11",
|
|
22
23
|
"Programming Language :: Python :: 3.12",
|
|
23
|
-
"License :: OSI Approved :: MIT License",
|
|
24
24
|
"Operating System :: OS Independent",
|
|
25
25
|
"Intended Audience :: Science/Research",
|
|
26
26
|
"Topic :: Scientific/Engineering",
|
|
@@ -31,3 +31,17 @@ def test_collapse_does_not_touch_headline():
|
|
|
31
31
|
pats = [p for p in Engine(obs, Config(min_lag=1)).detect() if p.level >= 1]
|
|
32
32
|
keys = _occ_keys(pats)
|
|
33
33
|
assert len(keys) == len(set(keys)) # headline path has no duplicates anyway
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_same_time_same_type_duplicates_collapse():
|
|
37
|
+
"""Same-type events at an identical timestamp collapse to one point (default);
|
|
38
|
+
they are retained only when the option is explicitly disabled."""
|
|
39
|
+
from tpattern import Observation, Engine, Config
|
|
40
|
+
obs = [Observation(name="o", start=0, end=200,
|
|
41
|
+
events=[(0, "A"), (0, "A"), (100, "A"), (0, "B")])]
|
|
42
|
+
eng = Engine(obs, Config()); eng.build_terminals()
|
|
43
|
+
n_A = next(p.N for p in eng.univariate if p.event == "A")
|
|
44
|
+
assert n_A == 2, f"collapse: A should be 2 distinct points, got {n_A}"
|
|
45
|
+
eng2 = Engine(obs, Config(collapse_duplicates=False)); eng2.build_terminals()
|
|
46
|
+
n_A2 = next(p.N for p in eng2.univariate if p.event == "A")
|
|
47
|
+
assert n_A2 == 3, f"no-collapse: A should retain all 3, got {n_A2}"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""compare_nulls: profile vs rotation decomposition into core and shell."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from tpattern import (Config, compare_nulls, NullComparison, calibrate,
|
|
5
|
+
null_comparison_plot)
|
|
6
|
+
from tpattern.synthetic import SyntheticSpec, make_dataset
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _planted(frac=0.15, seed=7):
|
|
10
|
+
# a dataset with a genuine A->B coupling planted on top of background
|
|
11
|
+
obs, _ = make_dataset(SyntheticSpec(
|
|
12
|
+
n_seq=300, planted=[("A", 0), ("B", 1500)], plant_fraction=frac,
|
|
13
|
+
jitter=200, bg_rate=2.5, seed=seed))
|
|
14
|
+
return obs
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_compare_nulls_structure():
|
|
18
|
+
obs = _planted()
|
|
19
|
+
cfg = Config(min_lag=1)
|
|
20
|
+
cmp = compare_nulls(obs, cfg, nulls=("profile", "rotation"), B=100, seed=1)
|
|
21
|
+
assert isinstance(cmp, NullComparison)
|
|
22
|
+
assert set(cmp.nulls) == {"profile", "rotation"}
|
|
23
|
+
# detection is null-independent: both nulls see the same detected patterns
|
|
24
|
+
assert set(cmp.detected["profile"]) == set(cmp.detected["rotation"])
|
|
25
|
+
# core is exactly the intersection of the two survivor sets
|
|
26
|
+
assert cmp.common == cmp.survives["profile"] & cmp.survives["rotation"]
|
|
27
|
+
# unique sets are disjoint from the core and from each other
|
|
28
|
+
assert cmp.unique["profile"].isdisjoint(cmp.common)
|
|
29
|
+
assert cmp.unique["rotation"].isdisjoint(cmp.common)
|
|
30
|
+
assert cmp.unique["profile"].isdisjoint(cmp.unique["rotation"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_rows_and_layers_consistent():
|
|
34
|
+
obs = _planted()
|
|
35
|
+
cmp = compare_nulls(obs, Config(min_lag=1), B=100, seed=2)
|
|
36
|
+
for r in cmp.rows():
|
|
37
|
+
surv = [n for n in cmp.nulls if r["survives"][n]]
|
|
38
|
+
assert surv, "a row must survive at least one null"
|
|
39
|
+
if len(surv) == len(cmp.nulls):
|
|
40
|
+
assert r["layer"] == "core"
|
|
41
|
+
elif len(surv) == 1:
|
|
42
|
+
assert r["layer"] == f"{surv[0]}-only"
|
|
43
|
+
# every listed q is a probability
|
|
44
|
+
for n in cmp.nulls:
|
|
45
|
+
q = r["q"][n]
|
|
46
|
+
assert q is None or 0.0 <= q <= 1.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_matches_individual_calibrate():
|
|
50
|
+
# compare_nulls must agree with calling calibrate() per null
|
|
51
|
+
obs = _planted()
|
|
52
|
+
cfg = Config(min_lag=1)
|
|
53
|
+
cmp = compare_nulls(obs, cfg, nulls=("profile", "rotation"), B=100, seed=3)
|
|
54
|
+
for null in ("profile", "rotation"):
|
|
55
|
+
r = calibrate(obs, cfg, null=null, B=100, seed=3)
|
|
56
|
+
assert cmp.survives[null] == {c.pattern.signature() for c in r.kept("fdr")}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_plot_writes_file(tmp_path):
|
|
60
|
+
obs = _planted()
|
|
61
|
+
cmp = compare_nulls(obs, Config(min_lag=1), B=100, seed=4)
|
|
62
|
+
out = tmp_path / "cmp.png"
|
|
63
|
+
null_comparison_plot(cmp, str(out), title="test")
|
|
64
|
+
assert out.exists() and out.stat().st_size > 0
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Bout support is *reported*, not filtered on.
|
|
2
|
+
|
|
3
|
+
The support threshold (`min_samples_frac`) is a prevalence filter, not a
|
|
4
|
+
significance test, so the library's job is to make prevalence visible rather than
|
|
5
|
+
to cut on it. These tests pin that contract: every results row carries the number
|
|
6
|
+
of distinct bouts and the support fraction, and the profile plot renders from a
|
|
7
|
+
calibration result.
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
13
|
+
|
|
14
|
+
from tpattern import (Config, Engine, calibrate, patterns_table, # noqa: E402
|
|
15
|
+
support_profile)
|
|
16
|
+
from tpattern.synthetic import SyntheticSpec, make_dataset # noqa: E402
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _sample():
|
|
20
|
+
spec = SyntheticSpec(n_seq=120, planted=[("A", 0), ("B", 1500)],
|
|
21
|
+
plant_fraction=0.35, seed=5)
|
|
22
|
+
obs, truth = make_dataset(spec)
|
|
23
|
+
return obs, truth
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_table_reports_bouts_and_support():
|
|
27
|
+
obs, _ = _sample()
|
|
28
|
+
result = calibrate(obs, Config(min_lag=1), null="profile", B=30, seed=1)
|
|
29
|
+
rows = patterns_table(result)
|
|
30
|
+
assert rows, "expected at least one detected pattern"
|
|
31
|
+
for row in rows:
|
|
32
|
+
# bouts counts DISTINCT observations; N counts occurrences and may exceed it
|
|
33
|
+
assert 1 <= row["bouts"] <= len(obs)
|
|
34
|
+
assert row["bouts"] <= row["N"]
|
|
35
|
+
assert 0.0 < row["bout_support"] <= 1.0
|
|
36
|
+
assert abs(row["bout_support"] - row["bouts"] / len(obs)) < 1e-6
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_support_needs_sample_size_when_uncalibrated():
|
|
40
|
+
"""A plain pattern list has no sample size, so support is None unless given."""
|
|
41
|
+
obs, _ = _sample()
|
|
42
|
+
pats = [p for p in Engine(obs, Config(min_lag=1)).detect() if p.level >= 1]
|
|
43
|
+
assert patterns_table(pats)[0]["bout_support"] is None
|
|
44
|
+
rows = patterns_table(pats, n_observations=len(obs))
|
|
45
|
+
assert rows[0]["bout_support"] is not None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_calibration_result_carries_sample_size():
|
|
49
|
+
obs, _ = _sample()
|
|
50
|
+
result = calibrate(obs, Config(min_lag=1), null="profile", B=30, seed=1)
|
|
51
|
+
assert result.n_observations == len(obs)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_support_profile_renders(tmp_path):
|
|
55
|
+
obs, _ = _sample()
|
|
56
|
+
result = calibrate(obs, Config(min_lag=1), null="profile", B=30, seed=1)
|
|
57
|
+
out = tmp_path / "profile.png"
|
|
58
|
+
key = support_profile(result, str(out), mark_thresholds=(0.10,))
|
|
59
|
+
assert out.exists() and out.stat().st_size > 0
|
|
60
|
+
# returned key is ordered by descending support, so a caption can be built from it
|
|
61
|
+
assert key == sorted(key, key=lambda kv: -kv[1])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_support_profile_handles_no_survivors(tmp_path):
|
|
65
|
+
"""Pure-null data: nothing survives, and the plot must still render."""
|
|
66
|
+
obs, _ = make_dataset(SyntheticSpec(n_seq=80, plant_fraction=0.0, seed=9))
|
|
67
|
+
result = calibrate(obs, Config(min_lag=1), null="profile", B=30, seed=2)
|
|
68
|
+
out = tmp_path / "empty.png"
|
|
69
|
+
key = support_profile(result, str(out))
|
|
70
|
+
assert out.exists()
|
|
71
|
+
assert key == [] or all(s > 0 for _, s in key)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_recommend_reports_duplicates_note():
|
|
75
|
+
"""recommend() surfaces same-instant duplicates as a note, not a recommendation."""
|
|
76
|
+
from tpattern import Observation, recommend
|
|
77
|
+
obs = [Observation(name=f"o{i}", start=0, end=100,
|
|
78
|
+
events=[(0, "A"), (0, "A"), (50, "B")]) for i in range(20)]
|
|
79
|
+
rec = recommend(obs)
|
|
80
|
+
assert any("duplicate" in n.lower() for n in rec.notes), rec.notes
|
|
81
|
+
# clean data: no duplicate note
|
|
82
|
+
clean = [Observation(name=f"c{i}", start=0, end=100,
|
|
83
|
+
events=[(0, "A"), (50, "B")]) for i in range(20)]
|
|
84
|
+
assert not any("duplicate" in n.lower() for n in recommend(clean).notes)
|
|
@@ -6,21 +6,22 @@ behavior: T-patterns and their detection. Behavior Research Methods,
|
|
|
6
6
|
Instruments, & Computers, 32(1), 93-110.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
__version__ = "0.1.5" # defined before submodule imports (methods.py reads it)
|
|
10
|
+
|
|
9
11
|
from .io import Observation, read_observation, read_sample, read_table
|
|
10
12
|
from .pattern import Instance, Pattern
|
|
11
13
|
from .ci import find_critical_interval, CIResult
|
|
12
14
|
from .detect import Config, Engine
|
|
13
15
|
from .randomise import run_null, NullResult, rotate, shuffle
|
|
14
|
-
from .significance import calibrate, CalibrationResult
|
|
16
|
+
from .significance import calibrate, CalibrationResult, compare_nulls, NullComparison
|
|
15
17
|
from .report import patterns_table, forest_plot, report
|
|
16
|
-
from .viz import pattern_dendrogram, patterns_overview
|
|
18
|
+
from .viz import (pattern_dendrogram, patterns_overview, support_profile,
|
|
19
|
+
null_comparison_plot)
|
|
17
20
|
from .advisor import recommend
|
|
18
21
|
from .methods import methods_text
|
|
19
22
|
from .guided import run_analysis, launch
|
|
20
23
|
from .contrast import group_contrast, contrast_items
|
|
21
24
|
|
|
22
|
-
__version__ = "0.1.2"
|
|
23
|
-
|
|
24
25
|
__all__ = [
|
|
25
26
|
"Observation", "read_observation", "read_sample", "read_table",
|
|
26
27
|
"Instance", "Pattern",
|
|
@@ -29,7 +30,7 @@ __all__ = [
|
|
|
29
30
|
"run_null", "NullResult", "rotate", "shuffle",
|
|
30
31
|
"calibrate", "CalibrationResult",
|
|
31
32
|
"patterns_table", "forest_plot", "report",
|
|
32
|
-
"pattern_dendrogram", "patterns_overview",
|
|
33
|
+
"pattern_dendrogram", "patterns_overview", "support_profile",
|
|
33
34
|
"recommend", "methods_text",
|
|
34
35
|
"run_analysis", "launch",
|
|
35
36
|
"group_contrast", "contrast_items",
|
|
@@ -47,11 +47,14 @@ class Recommendation:
|
|
|
47
47
|
n_obs: int
|
|
48
48
|
n_events: int
|
|
49
49
|
choices: list[Choice]
|
|
50
|
+
notes: list[str] = field(default_factory=list)
|
|
50
51
|
|
|
51
52
|
def __str__(self) -> str:
|
|
52
53
|
lines = [f"Dataset: {self.n_obs} observations, {self.n_events} events\n"]
|
|
53
54
|
for c in self.choices:
|
|
54
55
|
lines.append(f"[{c.option}] -> {c.recommended}\n {c.rationale}")
|
|
56
|
+
for n in self.notes:
|
|
57
|
+
lines.append(f"[note] {n}")
|
|
55
58
|
return "\n".join(lines)
|
|
56
59
|
|
|
57
60
|
def methods_text(self) -> str:
|
|
@@ -106,7 +109,12 @@ def recommend(observations, *, min_count: int = 15,
|
|
|
106
109
|
f"(KS p<{uniformity_alpha}){ex_txt}, so the profile-preserving "
|
|
107
110
|
f"null (N2) was used to isolate cross-event coupling from each "
|
|
108
111
|
f"type's marginal timing; the rotation null (N1) is reported "
|
|
109
|
-
f"alongside to quantify the marginal-timing contribution."
|
|
112
|
+
f"alongside to quantify the marginal-timing contribution. The "
|
|
113
|
+
f"two nulls also pose different questions: N1 tests whether any "
|
|
114
|
+
f"temporal structure is present, N2 whether events are coupled "
|
|
115
|
+
f"beyond their own timing (the usual T-pattern claim), so N2 is "
|
|
116
|
+
f"recommended here; choose N1 if your question is only whether "
|
|
117
|
+
f"structure exists."),
|
|
110
118
|
plain=("<b>Testing patterns against chance — without being fooled by "
|
|
111
119
|
"timing.</b> Some actions naturally tend to happen at particular "
|
|
112
120
|
"points (for example, shots come late in a move), so two actions can "
|
|
@@ -272,6 +280,29 @@ def recommend(observations, *, min_count: int = 15,
|
|
|
272
280
|
evidence={"floor_at_200": 1/201, "floor_at_2000": 1/2001},
|
|
273
281
|
)
|
|
274
282
|
|
|
283
|
+
# --- diagnostic (reported, not recommended): same-instant duplicate records ---
|
|
284
|
+
notes = []
|
|
285
|
+
n_dup = 0
|
|
286
|
+
for o in observations:
|
|
287
|
+
seen = set()
|
|
288
|
+
for t, ev in o.events:
|
|
289
|
+
if (ev, t) in seen:
|
|
290
|
+
n_dup += 1
|
|
291
|
+
else:
|
|
292
|
+
seen.add((ev, t))
|
|
293
|
+
if n_dup:
|
|
294
|
+
res = _resolution_stats(observations)
|
|
295
|
+
gap = res.get("min_nonzero_gap")
|
|
296
|
+
coarse = (f" Your finest inter-event gap is {gap} time units, so if that resolution is "
|
|
297
|
+
f"coarse for your data these may be genuinely distinct events; set "
|
|
298
|
+
f"collapse_duplicates=False to keep them.") if gap and gap > 1 else ""
|
|
299
|
+
notes.append(
|
|
300
|
+
f"{n_dup} same-instant duplicate record(s) (same event type at an identical timestamp) "
|
|
301
|
+
f"were found and are collapsed to one point by default (collapse_duplicates): two records "
|
|
302
|
+
f"at the same instant occupy one point and retaining both would inflate that type's "
|
|
303
|
+
f"baseline rate.{coarse}")
|
|
304
|
+
|
|
275
305
|
return Recommendation(n_obs=n_obs, n_events=n_events,
|
|
276
306
|
choices=[freq_choice, min_occ_choice, null_choice, lag_choice,
|
|
277
|
-
err_choice, B_choice]
|
|
307
|
+
err_choice, B_choice],
|
|
308
|
+
notes=notes)
|
|
@@ -90,11 +90,27 @@ class Config:
|
|
|
90
90
|
# a genuine temporal lag, so same-timestamp
|
|
91
91
|
# co-occurrences are excluded and handled
|
|
92
92
|
# separately as "simultaneity".
|
|
93
|
-
freq_exclude: float | None = 1.50 # auto exclude
|
|
93
|
+
freq_exclude: float | None = 1.50 # auto exclude threshold; meaning depends on
|
|
94
|
+
# freq_exclude_mode below
|
|
95
|
+
freq_exclude_mode: str = "per_observation" # "per_observation" (default): exclude a
|
|
96
|
+
# type whose MEAN OCCURRENCES PER OBSERVATION exceed freq_exclude.
|
|
97
|
+
# "theme_sd": THEME's documented rule (Theme 6 manual, "Exclude Frequent Event
|
|
98
|
+
# Types") -- exclude a type whose total frequency exceeds mean + x*SD of the
|
|
99
|
+
# event-type frequency distribution, where x = freq_exclude. THEME suggests 2.5
|
|
100
|
+
# and notes 1.5-2.5 often works. The two rules are different quantities that can
|
|
101
|
+
# share a number, which is why a value of 1.50 does not mean the same thing in
|
|
102
|
+
# both tools.
|
|
94
103
|
exclude_events: list[str] | None = None # explicit exclusion list (overrides
|
|
95
104
|
# the frequency rule when provided;
|
|
96
105
|
# [] disables exclusion entirely)
|
|
97
106
|
include_univariate: bool = True # Univariate Patterns = Include
|
|
107
|
+
collapse_duplicates: bool = True # collapse same-type events sharing an identical
|
|
108
|
+
# timestamp to one point. In the point process the detector is built on, the
|
|
109
|
+
# unit is (type, time): two records of one type at the same instant occupy a
|
|
110
|
+
# single point (Δt = 0, no ordering information), and retaining both inflates
|
|
111
|
+
# N_B in the NX/T baseline without changing window occupancy. Collapsing is the
|
|
112
|
+
# treatment consistent with that baseline; it also matches THEME's import.
|
|
113
|
+
# (Different types at Δt = 0 are genuine co-occurrence — that is min_lag's job.)
|
|
98
114
|
completeness: bool = True # apply completeness competition
|
|
99
115
|
collapse_equivalent: bool = True # collapse occurrence-identical patterns (the
|
|
100
116
|
# two directions of a co-timed pair, or different bracketings of one chain)
|
|
@@ -112,6 +128,25 @@ class Config:
|
|
|
112
128
|
# so it is pruned during the search.
|
|
113
129
|
max_level: int = 8 # safety cap on hierarchy depth
|
|
114
130
|
|
|
131
|
+
def excluded_types(self, counts: dict, n_obs: int) -> set:
|
|
132
|
+
"""Which event types are barred from *building* patterns.
|
|
133
|
+
|
|
134
|
+
`counts` maps event type -> total number of occurrences. Returns the set to
|
|
135
|
+
exclude, resolving `freq_exclude_mode`.
|
|
136
|
+
"""
|
|
137
|
+
if self.exclude_events is not None:
|
|
138
|
+
return set(self.exclude_events)
|
|
139
|
+
if self.freq_exclude is None or not counts:
|
|
140
|
+
return set()
|
|
141
|
+
if self.freq_exclude_mode == "theme_sd":
|
|
142
|
+
vals = list(counts.values())
|
|
143
|
+
mean = sum(vals) / len(vals)
|
|
144
|
+
var = sum((v - mean) ** 2 for v in vals) / len(vals)
|
|
145
|
+
sd = var ** 0.5
|
|
146
|
+
cut = mean + self.freq_exclude * sd
|
|
147
|
+
return {ev for ev, c in counts.items() if c > cut}
|
|
148
|
+
return {ev for ev, c in counts.items() if c / n_obs > self.freq_exclude}
|
|
149
|
+
|
|
115
150
|
def is_excluded(self, event: str, mean_per_obs: float) -> bool:
|
|
116
151
|
"""Decide whether an event type is barred from *building* patterns.
|
|
117
152
|
|
|
@@ -162,18 +197,28 @@ class Engine:
|
|
|
162
197
|
by_type: dict[str, list[Instance]] = defaultdict(list)
|
|
163
198
|
token = 0
|
|
164
199
|
for i, o in enumerate(self.obs):
|
|
200
|
+
seen_pt: set[tuple[str, int]] = set() # (type, time) points seen in this obs
|
|
165
201
|
for t, ev in o.events:
|
|
166
|
-
|
|
167
|
-
|
|
202
|
+
if self.cfg.collapse_duplicates:
|
|
203
|
+
# Same type at an identical timestamp = one point in the process;
|
|
204
|
+
# keep the first, drop repeats (they add no window information and
|
|
205
|
+
# would inflate the NX/T baseline). See Config.collapse_duplicates.
|
|
206
|
+
if (ev, t) in seen_pt:
|
|
207
|
+
continue
|
|
208
|
+
seen_pt.add((ev, t))
|
|
209
|
+
# Every retained occurrence gets a globally-unique token id so that
|
|
210
|
+
# composite patterns can never reuse the same event twice.
|
|
168
211
|
by_type[ev].append(Instance(obs=i, start=t, end=t, tokens=frozenset((token,))))
|
|
169
212
|
token += 1
|
|
170
213
|
|
|
171
214
|
n_obs = len(self.obs)
|
|
215
|
+
counts = {ev: len(insts) for ev, insts in by_type.items()}
|
|
216
|
+
excluded = self.cfg.excluded_types(counts, n_obs)
|
|
172
217
|
self.univariate, self.terminals, self.excluded = [], [], []
|
|
173
218
|
for ev, insts in sorted(by_type.items()):
|
|
174
219
|
term = Pattern(event=ev, instances=insts)
|
|
175
220
|
self.univariate.append(term)
|
|
176
|
-
if
|
|
221
|
+
if ev in excluded:
|
|
177
222
|
self.excluded.append(ev)
|
|
178
223
|
continue
|
|
179
224
|
if len(insts) < self.cfg.min_occurrence:
|
|
@@ -16,7 +16,7 @@ operational definition, generated from the actual `Config` that was run.
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
-
VERSION
|
|
19
|
+
from . import __version__ as VERSION # single source of truth (pyproject/__init__)
|
|
20
20
|
CONCEPT_DOI = "10.5281/zenodo.21397543"
|
|
21
21
|
|
|
22
22
|
|
|
@@ -82,6 +82,11 @@ def methods_text(config, *, observations=None, calibration=None,
|
|
|
82
82
|
"eligible to build patterns.")
|
|
83
83
|
|
|
84
84
|
# --- concurrency handling ---
|
|
85
|
+
if getattr(config, "collapse_duplicates", True):
|
|
86
|
+
s.append(
|
|
87
|
+
"Multiple records of one event type at an identical timestamp were "
|
|
88
|
+
"collapsed to a single occurrence, since they occupy one point in the "
|
|
89
|
+
"process and retaining them would inflate that type's baseline rate.")
|
|
85
90
|
if getattr(config, "min_lag", 0) and config.min_lag >= 1:
|
|
86
91
|
s.append(
|
|
87
92
|
f"A genuine temporal gap of at least {config.min_lag} time unit(s) was "
|
|
@@ -99,10 +104,33 @@ def methods_text(config, *, observations=None, calibration=None,
|
|
|
99
104
|
if getattr(config, "completeness", True):
|
|
100
105
|
s.append("Patterns whose every occurrence was contained within a longer "
|
|
101
106
|
"detected pattern were removed (completeness competition).")
|
|
107
|
+
else:
|
|
108
|
+
s.append("Completeness competition was disabled, so patterns fully contained "
|
|
109
|
+
"within longer ones were retained and counted separately.")
|
|
110
|
+
# These change the reported counts and the size of the multiple-comparison family,
|
|
111
|
+
# so they belong in the Methods whether or not they are left at their defaults.
|
|
112
|
+
if getattr(config, "collapse_equivalent", True):
|
|
113
|
+
s.append("Patterns sharing an identical set of occurrences (the two directions "
|
|
114
|
+
"of a co-timed pair, or different bracketings of one chain) were "
|
|
115
|
+
"collapsed to a single representative before counting and before "
|
|
116
|
+
"correction, so perfectly dependent duplicates were neither reported "
|
|
117
|
+
"nor tested twice.")
|
|
118
|
+
else:
|
|
119
|
+
s.append("Occurrence-equivalent patterns were not collapsed, so different "
|
|
120
|
+
"bracketings of the same chain appear as separate patterns and enter "
|
|
121
|
+
"the multiple-comparison family separately.")
|
|
122
|
+
if not getattr(config, "include_univariate", True):
|
|
123
|
+
s.append("Univariate (single-event) patterns were excluded from the output.")
|
|
102
124
|
if getattr(config, "lumping_factor", None) is not None:
|
|
103
125
|
s.append(f"A lumping factor of {config.lumping_factor} was applied: when a "
|
|
104
126
|
f"pair (A, B) formed with a forward conditional probability above this "
|
|
105
127
|
f"value, the dependent terminal was removed from the remaining search.")
|
|
128
|
+
if getattr(config, "max_edges", None) is not None:
|
|
129
|
+
s.append(f"The critical-interval search was capped at {config.max_edges} "
|
|
130
|
+
f"candidate window boundaries per pair, so the window search was "
|
|
131
|
+
f"approximate rather than exhaustive.")
|
|
132
|
+
if getattr(config, "max_level", 8) != 8:
|
|
133
|
+
s.append(f"The pattern hierarchy was capped at {config.max_level} levels.")
|
|
106
134
|
if getattr(config, "min_samples_frac", None) is not None:
|
|
107
135
|
s.append(f"Patterns were additionally required to occur in at least "
|
|
108
136
|
f"{config.min_samples_frac:.0%} of observations.")
|
|
@@ -66,23 +66,37 @@ def interpret(pattern, N, fdr_q, *, q_target=0.05, ci_unit=""):
|
|
|
66
66
|
|
|
67
67
|
|
|
68
68
|
def patterns_table(source, outfile: str | None = None, ci_unit: str = "",
|
|
69
|
-
sort: str = "auto"):
|
|
69
|
+
sort: str = "auto", n_observations: int = 0):
|
|
70
70
|
"""Build a results table from a list of `Pattern` or a `CalibrationResult`.
|
|
71
71
|
|
|
72
72
|
Returns a list of row dicts. If `outfile` is given, also writes CSV.
|
|
73
73
|
When given a CalibrationResult, includes p_emp, fdr_q, fwer_keep and sorts by
|
|
74
74
|
p_emp; otherwise sorts by N (descending).
|
|
75
|
+
|
|
76
|
+
Every row carries `bouts` (distinct observations the pattern spans) and
|
|
77
|
+
`bout_support` (that as a fraction of the sample). Prevalence is reported rather
|
|
78
|
+
than filtered on: a reader can apply whatever support threshold they consider
|
|
79
|
+
appropriate, which a pre-applied cut-off would have denied them. Pass
|
|
80
|
+
`n_observations` to get support when `source` is a plain list of patterns.
|
|
75
81
|
"""
|
|
76
82
|
calibrated = isinstance(source, CalibrationResult)
|
|
77
83
|
items = source.real if calibrated else [p for p in source if p.level >= 1]
|
|
84
|
+
n_obs = n_observations or (source.n_observations if calibrated else 0)
|
|
78
85
|
|
|
79
86
|
rows = []
|
|
80
87
|
for it in items:
|
|
81
88
|
p = it.pattern if calibrated else it
|
|
89
|
+
# Distinct observations (bouts) the pattern spans. N counts occurrences and
|
|
90
|
+
# can exceed this, because one bout may contain the pattern several times.
|
|
91
|
+
# Reported so prevalence is visible as a number: a reader can then apply any
|
|
92
|
+
# support threshold they wish, rather than trusting one applied for them.
|
|
93
|
+
bouts = len({inst.obs for inst in p.instances})
|
|
82
94
|
row = {
|
|
83
95
|
"pattern": str(p),
|
|
84
96
|
"signature": p.signature(),
|
|
85
97
|
"N": p.N,
|
|
98
|
+
"bouts": bouts,
|
|
99
|
+
"bout_support": round(bouts / n_obs, 4) if n_obs else None,
|
|
86
100
|
"length": p.length,
|
|
87
101
|
"level": p.level,
|
|
88
102
|
"loop": int(p.has_loop),
|
|
@@ -76,6 +76,7 @@ class CalibrationResult:
|
|
|
76
76
|
alpha: float
|
|
77
77
|
q_target: float
|
|
78
78
|
null: str
|
|
79
|
+
n_observations: int = 0 # sample size, so bout support can be reported
|
|
79
80
|
|
|
80
81
|
def kept(self, method: str = "fdr") -> list[Calibrated]:
|
|
81
82
|
if method == "fwer":
|
|
@@ -221,4 +222,123 @@ def calibrate(observations, config: Config | None = None, *,
|
|
|
221
222
|
c.fdr_q = qmap[id(c)]
|
|
222
223
|
|
|
223
224
|
return CalibrationResult(real=real, surrogate_counts=surrogate_counts,
|
|
224
|
-
B=B, alpha=alpha, q_target=q_target, null=null
|
|
225
|
+
B=B, alpha=alpha, q_target=q_target, null=null,
|
|
226
|
+
n_observations=len(observations))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---- comparing nulls ---------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
@dataclass
|
|
232
|
+
class NullComparison:
|
|
233
|
+
"""Calibration of one sample under several surrogate nulls, and their difference.
|
|
234
|
+
|
|
235
|
+
The informative output is the DIFFERENCE between nulls, not either null alone.
|
|
236
|
+
With the profile and rotation nulls the surviving sets nest, and split into:
|
|
237
|
+
|
|
238
|
+
* the *core* -- patterns surviving under every null: the coupling (sequence)
|
|
239
|
+
claim, genuine coordination beyond each event type's own timing;
|
|
240
|
+
* the *shell* -- patterns surviving the rotation null but not the profile null:
|
|
241
|
+
co-occurrence driven by shared timing (two types that inhabit the same phase
|
|
242
|
+
of play) rather than by coordination.
|
|
243
|
+
|
|
244
|
+
The shell is a finding in its own right, not noise: it answers "which actions
|
|
245
|
+
occupy the same moment?" while the core answers "which actions are chained?".
|
|
246
|
+
Build one with compare_nulls().
|
|
247
|
+
"""
|
|
248
|
+
nulls: list
|
|
249
|
+
method: str
|
|
250
|
+
q_target: float
|
|
251
|
+
results: dict # null -> CalibrationResult
|
|
252
|
+
detected: dict # null -> {signature: Calibrated} (detection is null-independent)
|
|
253
|
+
survives: dict # null -> set(signature) surviving the correction
|
|
254
|
+
common: set # signatures surviving under ALL nulls (the core)
|
|
255
|
+
unique: dict # null -> set(signature) surviving under that null alone
|
|
256
|
+
|
|
257
|
+
def _q(self, null, sig):
|
|
258
|
+
c = self.detected[null].get(sig)
|
|
259
|
+
return None if c is None else c.fdr_q
|
|
260
|
+
|
|
261
|
+
def _N(self, sig):
|
|
262
|
+
for d in self.detected.values():
|
|
263
|
+
if sig in d:
|
|
264
|
+
return d[sig].N
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
def layer(self, sig):
|
|
268
|
+
"""Which layer a surviving pattern occupies: 'core', '<null>-only', or a join."""
|
|
269
|
+
surv = tuple(n for n in self.nulls if sig in self.survives[n])
|
|
270
|
+
if len(surv) == len(self.nulls):
|
|
271
|
+
return "core"
|
|
272
|
+
if len(surv) == 1:
|
|
273
|
+
return f"{surv[0]}-only"
|
|
274
|
+
return "+".join(surv) if surv else "none"
|
|
275
|
+
|
|
276
|
+
def rows(self):
|
|
277
|
+
"""One row per pattern surviving under at least one null, most significant first.
|
|
278
|
+
|
|
279
|
+
Each row: signature, N, layer, and per-null (survives, q). Tidy for a table.
|
|
280
|
+
"""
|
|
281
|
+
anysurv = set().union(*self.survives.values()) if self.survives else set()
|
|
282
|
+
sigs = sorted(anysurv, key=lambda s: min((self._q(n, s) or 1.0) for n in self.nulls))
|
|
283
|
+
return [{
|
|
284
|
+
"signature": s,
|
|
285
|
+
"N": self._N(s),
|
|
286
|
+
"layer": self.layer(s),
|
|
287
|
+
"survives": {n: (s in self.survives[n]) for n in self.nulls},
|
|
288
|
+
"q": {n: self._q(n, s) for n in self.nulls},
|
|
289
|
+
} for s in sigs]
|
|
290
|
+
|
|
291
|
+
def summary(self):
|
|
292
|
+
"""Short text summary that leads with the difference between nulls."""
|
|
293
|
+
pr = set(self.nulls) >= {"profile", "rotation"}
|
|
294
|
+
lines = [f"{n:10s}: {len(self.survives[n])} survive ({self.method.upper()} q<={self.q_target})"
|
|
295
|
+
for n in self.nulls]
|
|
296
|
+
lines.append(f"core (survives all{', the coupling claim' if pr else ''}): {len(self.common)}")
|
|
297
|
+
for n in self.nulls:
|
|
298
|
+
if self.unique[n]:
|
|
299
|
+
tag = " (shared-timing shell)" if (n == "rotation" and "profile" in self.nulls) else ""
|
|
300
|
+
lines.append(f"{n}-only{tag}: {len(self.unique[n])}")
|
|
301
|
+
return "\n".join(lines)
|
|
302
|
+
|
|
303
|
+
def __str__(self):
|
|
304
|
+
head = f"Null comparison [{', '.join(self.nulls)}]"
|
|
305
|
+
tab = [f" {'pattern':<50s} {'N':>4s} "
|
|
306
|
+
+ " ".join(f"{n[:8]:>9s}" for n in self.nulls) + " layer"]
|
|
307
|
+
for r in self.rows():
|
|
308
|
+
cells = " ".join(
|
|
309
|
+
((f"{r['q'][n]:.3f}" if r['q'][n] is not None else " - ")
|
|
310
|
+
+ ("*" if r['survives'][n] else " ")).rjust(9)
|
|
311
|
+
for n in self.nulls)
|
|
312
|
+
tab.append(f" {r['signature'][:50]:<50s} {r['N']:>4d} {cells} {r['layer']}")
|
|
313
|
+
return (head + "\n" + self.summary() + "\n\n" + "\n".join(tab)
|
|
314
|
+
+ "\n (* = survives the correction; q shown per null)")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def compare_nulls(observations, config=None, *, nulls=("profile", "rotation"),
|
|
318
|
+
B=200, alpha=0.005, q_target=0.05, method="fdr", seed=20260714):
|
|
319
|
+
"""Calibrate one sample under several nulls and decompose the survivors.
|
|
320
|
+
|
|
321
|
+
This is the 'both' option: it runs calibrate() once per null and reports, for
|
|
322
|
+
every pattern, which nulls it survives under, so the DIFFERENCE between nulls is
|
|
323
|
+
explicit. With the default profile-vs-rotation pair the result splits into a
|
|
324
|
+
coupling core (survives both) and a shared-timing shell (rotation-only); the
|
|
325
|
+
shell is a positive finding, not noise (see NullComparison).
|
|
326
|
+
|
|
327
|
+
Parameters mirror calibrate(); `nulls` is the tuple of nulls to compare and
|
|
328
|
+
`method` selects "fdr" or "fwer" for deciding survival. Detection is identical
|
|
329
|
+
across nulls, so only the calibration is repeated.
|
|
330
|
+
|
|
331
|
+
Returns a NullComparison: print it for a table, or use .rows()/.common/.unique.
|
|
332
|
+
"""
|
|
333
|
+
config = config or Config()
|
|
334
|
+
nulls = list(nulls)
|
|
335
|
+
results = {n: calibrate(observations, config, null=n, B=B, alpha=alpha,
|
|
336
|
+
q_target=q_target, seed=seed) for n in nulls}
|
|
337
|
+
detected = {n: {c.pattern.signature(): c for c in results[n].real} for n in nulls}
|
|
338
|
+
survives = {n: {c.pattern.signature() for c in results[n].kept(method)} for n in nulls}
|
|
339
|
+
common = set.intersection(*survives.values()) if survives else set()
|
|
340
|
+
unique = {n: survives[n] - set().union(*[survives[m] for m in nulls if m != n])
|
|
341
|
+
for n in nulls}
|
|
342
|
+
return NullComparison(nulls=nulls, method=method, q_target=q_target,
|
|
343
|
+
results=results, detected=detected, survives=survives,
|
|
344
|
+
common=common, unique=unique)
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Visual output for detected T-patterns — clean, publication-ready figures.
|
|
3
|
+
|
|
4
|
+
The centrepiece is `pattern_dendrogram`: a T-pattern is a binary tree of
|
|
5
|
+
critical-interval links, and this draws it as a proper dendrogram — event types
|
|
6
|
+
as leaves, each internal join annotated with its critical interval [d1, d2] (the
|
|
7
|
+
time window within which the right side follows the left). THEME draws this as a
|
|
8
|
+
cramped detection tree; here it is a clean, labelled figure.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import matplotlib
|
|
14
|
+
matplotlib.use("Agg")
|
|
15
|
+
import matplotlib.pyplot as plt
|
|
16
|
+
|
|
17
|
+
from .pattern import Pattern
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _layout(p: Pattern, leaves: list, pos: dict):
|
|
21
|
+
"""Assign (x, depth) to every node. Leaves get sequential x; internal nodes
|
|
22
|
+
sit at the mid-x of their children, depth = 1 + max child depth."""
|
|
23
|
+
if p.is_terminal:
|
|
24
|
+
x = len(leaves)
|
|
25
|
+
leaves.append(p.event)
|
|
26
|
+
pos[id(p)] = (x, 0)
|
|
27
|
+
return x, 0
|
|
28
|
+
lx, ld = _layout(p.left, leaves, pos)
|
|
29
|
+
rx, rd = _layout(p.right, leaves, pos)
|
|
30
|
+
x = (lx + rx) / 2.0
|
|
31
|
+
d = 1 + max(ld, rd)
|
|
32
|
+
pos[id(p)] = (x, d)
|
|
33
|
+
return x, d
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _draw(p: Pattern, pos: dict, ax, ci_unit: str):
|
|
37
|
+
"""Recursively draw dendrogram elbows from each internal node to its children."""
|
|
38
|
+
if p.is_terminal:
|
|
39
|
+
return
|
|
40
|
+
x, d = pos[id(p)]
|
|
41
|
+
for child in (p.left, p.right):
|
|
42
|
+
cx, cd = pos[id(child)]
|
|
43
|
+
# elbow: up from child to this node's depth, then across
|
|
44
|
+
ax.plot([cx, cx], [cd, d], color="#34495e", lw=1.4)
|
|
45
|
+
ax.plot([cx, x], [d, d], color="#34495e", lw=1.4)
|
|
46
|
+
_draw(child, pos, ax, ci_unit)
|
|
47
|
+
# annotate the critical interval on the join
|
|
48
|
+
if p.ci is not None:
|
|
49
|
+
d1, d2 = p.ci
|
|
50
|
+
ax.annotate(f"[{d1},{d2}]{ci_unit}", (x, d), textcoords="offset points",
|
|
51
|
+
xytext=(0, 4), ha="center", fontsize=7, color="#c0392b")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pattern_dendrogram(pattern: Pattern, title: str | None = None,
|
|
55
|
+
ci_unit: str = "", outfile: str | None = None, ax=None):
|
|
56
|
+
"""Draw one T-pattern as a dendrogram. Returns the Axes.
|
|
57
|
+
|
|
58
|
+
Leaves (event types) are labelled along the bottom in temporal order; each
|
|
59
|
+
internal join shows its critical interval. `pattern.N` and significance can
|
|
60
|
+
be put in the title.
|
|
61
|
+
"""
|
|
62
|
+
leaves: list = []
|
|
63
|
+
pos: dict = {}
|
|
64
|
+
_layout(pattern, leaves, pos)
|
|
65
|
+
|
|
66
|
+
own = ax is None
|
|
67
|
+
if own:
|
|
68
|
+
fig, ax = plt.subplots(figsize=(max(4, 1.1 * len(leaves)), 3.2))
|
|
69
|
+
_draw(pattern, pos, ax, ci_unit)
|
|
70
|
+
|
|
71
|
+
# leaf labels
|
|
72
|
+
for i, name in enumerate(leaves):
|
|
73
|
+
ax.plot([i], [0], "o", color="#2980b9", ms=6)
|
|
74
|
+
ax.annotate(name, (i, 0), textcoords="offset points", xytext=(0, -8),
|
|
75
|
+
ha="right", va="top", rotation=35, fontsize=8)
|
|
76
|
+
|
|
77
|
+
ax.set_xlim(-0.7, len(leaves) - 0.3)
|
|
78
|
+
ax.set_ylim(-1.2, pattern.level + 0.6)
|
|
79
|
+
ax.set_yticks(range(pattern.level + 1))
|
|
80
|
+
ax.set_ylabel("level")
|
|
81
|
+
ax.set_xticks([])
|
|
82
|
+
for s in ("top", "right", "bottom"):
|
|
83
|
+
ax.spines[s].set_visible(False)
|
|
84
|
+
if title:
|
|
85
|
+
ax.set_title(title, fontsize=9, wrap=True)
|
|
86
|
+
|
|
87
|
+
if own and outfile:
|
|
88
|
+
plt.tight_layout(); plt.savefig(outfile, dpi=150); plt.close()
|
|
89
|
+
return ax
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def patterns_overview(patterns: list, outfile: str, max_rows: int = 8,
|
|
93
|
+
ci_unit: str = "", by=lambda p: -p.N):
|
|
94
|
+
"""A grid of the top pattern dendrograms (by `by`, default most frequent)."""
|
|
95
|
+
comps = sorted([p for p in patterns if p.level >= 1], key=by)[:max_rows]
|
|
96
|
+
n = len(comps)
|
|
97
|
+
if not n:
|
|
98
|
+
return
|
|
99
|
+
cols = 2
|
|
100
|
+
rows = (n + cols - 1) // cols
|
|
101
|
+
fig, axes = plt.subplots(rows, cols, figsize=(12, 3.0 * rows))
|
|
102
|
+
axes = axes.flatten() if n > 1 else [axes]
|
|
103
|
+
for ax, p in zip(axes, comps):
|
|
104
|
+
pattern_dendrogram(p, title=f"N={p.N}, level {p.level}", ci_unit=ci_unit, ax=ax)
|
|
105
|
+
for ax in axes[n:]:
|
|
106
|
+
ax.axis("off")
|
|
107
|
+
plt.tight_layout(); plt.savefig(outfile, dpi=150); plt.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def support_profile(result, outfile: str, title: str | None = None,
|
|
111
|
+
mark_thresholds=(0.10, 0.25), max_rows: int = 25,
|
|
112
|
+
method: str = "fdr", color_by=None, highlight=None):
|
|
113
|
+
"""Ranked bout-support profile of the patterns that survived calibration.
|
|
114
|
+
|
|
115
|
+
One bar per surviving pattern, ordered by the fraction of observations (bouts)
|
|
116
|
+
it spans. This reports prevalence instead of filtering on it: the reader sees
|
|
117
|
+
each pattern's spread directly and can apply whatever support threshold they
|
|
118
|
+
consider defensible.
|
|
119
|
+
|
|
120
|
+
`mark_thresholds` draws reference lines for candidate thresholds. Read them as
|
|
121
|
+
"everything below this line would be deleted", not as recommended values. A
|
|
122
|
+
knee in this profile separates common patterns from rare ones; it does NOT
|
|
123
|
+
separate real from chance, because calibration has already done that. Cutting
|
|
124
|
+
at a knee therefore removes rare patterns that are demonstrably not noise, and
|
|
125
|
+
on real data the knee ordering and the evidence ordering can disagree entirely.
|
|
126
|
+
|
|
127
|
+
Colouring the bars answers a question that generalises to any dataset: *does some
|
|
128
|
+
family of events cluster at one end of the prevalence range?* Two ways to ask it:
|
|
129
|
+
|
|
130
|
+
* ``highlight="LooseBall"`` (or a list of terms) colours every pattern whose
|
|
131
|
+
signature contains that text. No code needed, works with whatever labels your
|
|
132
|
+
own coding scheme uses.
|
|
133
|
+
* ``color_by=fn`` takes a callable mapping a signature to a group label, for
|
|
134
|
+
groupings a substring cannot express.
|
|
135
|
+
|
|
136
|
+
Leave both None for a single colour.
|
|
137
|
+
|
|
138
|
+
Returns the list of (signature, support) pairs actually plotted.
|
|
139
|
+
"""
|
|
140
|
+
if color_by is None and highlight:
|
|
141
|
+
terms = [highlight] if isinstance(highlight, str) else list(highlight)
|
|
142
|
+
|
|
143
|
+
def color_by(sig, _terms=terms):
|
|
144
|
+
hit = next((t for t in _terms if t.lower() in sig.lower()), None)
|
|
145
|
+
return f"contains {hit}" if hit else "other patterns"
|
|
146
|
+
|
|
147
|
+
n_obs = getattr(result, "n_observations", 0)
|
|
148
|
+
items = []
|
|
149
|
+
for c in result.kept(method):
|
|
150
|
+
bouts = len({inst.obs for inst in c.pattern.instances})
|
|
151
|
+
items.append((c.pattern.signature(), bouts, bouts / n_obs if n_obs else 0.0))
|
|
152
|
+
items.sort(key=lambda x: -x[2])
|
|
153
|
+
shown = items[:max_rows]
|
|
154
|
+
|
|
155
|
+
n = len(shown)
|
|
156
|
+
# Long signatures are unreadable as rotated tick labels once there are more than
|
|
157
|
+
# a handful of bars, so rank-number them and let the caller print the key (the
|
|
158
|
+
# returned list) into the caption. Identity is recoverable, the shape stays clean.
|
|
159
|
+
number_bars = n > 6
|
|
160
|
+
fig, ax = plt.subplots(
|
|
161
|
+
figsize=((max(7.0, 0.42 * n + 2.2), 4.9) if number_bars
|
|
162
|
+
else (max(6.0, 1.15 * n + 2.6), 5.2)), dpi=200)
|
|
163
|
+
if not shown:
|
|
164
|
+
ax.text(0.5, 0.5, "No pattern survived calibration,\nso there is no prevalence to report.",
|
|
165
|
+
ha="center", va="center", fontsize=10, color="#5b6270")
|
|
166
|
+
ax.axis("off")
|
|
167
|
+
else:
|
|
168
|
+
xs = range(n)
|
|
169
|
+
sup = [s for _, _, s in shown]
|
|
170
|
+
if color_by is None:
|
|
171
|
+
bar_colors = "#4a6fa5"
|
|
172
|
+
else:
|
|
173
|
+
palette = ["#4a6fa5", "#c07a3e", "#5b8c5a", "#8a6d9a"]
|
|
174
|
+
groups, seen = [], {}
|
|
175
|
+
for sig, _, _ in shown:
|
|
176
|
+
g = color_by(sig)
|
|
177
|
+
seen.setdefault(g, palette[len(seen) % len(palette)])
|
|
178
|
+
groups.append(g)
|
|
179
|
+
bar_colors = [seen[g] for g in groups]
|
|
180
|
+
bars = ax.bar(list(xs), sup, color=bar_colors, edgecolor="#2c4a72",
|
|
181
|
+
width=0.68, zorder=3)
|
|
182
|
+
if color_by is not None:
|
|
183
|
+
from matplotlib.patches import Patch
|
|
184
|
+
ax.legend(handles=[Patch(facecolor=c, edgecolor="#2c4a72", label=g)
|
|
185
|
+
for g, c in seen.items()],
|
|
186
|
+
fontsize=7.4, frameon=False, loc="upper right")
|
|
187
|
+
ax.set_xticks(list(xs))
|
|
188
|
+
if number_bars:
|
|
189
|
+
ax.set_xticklabels([str(i + 1) for i in xs], fontsize=7.6)
|
|
190
|
+
ax.set_xlabel("pattern, ranked by prevalence (see caption for identities)",
|
|
191
|
+
fontsize=8.4)
|
|
192
|
+
else:
|
|
193
|
+
ax.set_xticklabels([sig if len(sig) <= 34 else sig[:31] + "..."
|
|
194
|
+
for sig, _, _ in shown],
|
|
195
|
+
rotation=45, ha="right", fontsize=6.6)
|
|
196
|
+
ax.set_ylabel("bout support\n(fraction of observations the pattern appears in)",
|
|
197
|
+
fontsize=8.4)
|
|
198
|
+
top = max(sup)
|
|
199
|
+
for i, (_, b, s) in enumerate(shown):
|
|
200
|
+
ax.text(i, s + top * 0.022, f"{s:.1%}", ha="center", va="bottom",
|
|
201
|
+
fontsize=6.2, color="#1a1c22", zorder=4)
|
|
202
|
+
for thr in mark_thresholds or ():
|
|
203
|
+
lost = sum(1 for _, _, s in items if s < thr)
|
|
204
|
+
ax.axhline(thr, color="#b0303a", lw=1.1, ls="--", alpha=0.85, zorder=1)
|
|
205
|
+
ax.text(n - 0.4, thr, f" {thr:.0%} would delete {lost} of {len(items)}",
|
|
206
|
+
fontsize=6.8, color="#b0303a", va="bottom", ha="right")
|
|
207
|
+
ax.set_ylim(0, max(top * 1.30,
|
|
208
|
+
(max(mark_thresholds) if mark_thresholds else 0) * 1.15))
|
|
209
|
+
ax.spines[["top", "right"]].set_visible(False)
|
|
210
|
+
ax.grid(axis="y", alpha=0.25, lw=0.6, zorder=0)
|
|
211
|
+
ax.yaxis.set_major_formatter(
|
|
212
|
+
matplotlib.ticker.FuncFormatter(lambda v, _: f"{v:.0%}"))
|
|
213
|
+
if title:
|
|
214
|
+
ax.set_title(title, fontsize=10.5, fontweight="bold")
|
|
215
|
+
if len(items) > max_rows:
|
|
216
|
+
fig.text(0.99, 0.005, f"showing {max_rows} of {len(items)} surviving patterns",
|
|
217
|
+
ha="right", fontsize=6.8, color="#8a93a3")
|
|
218
|
+
fig.tight_layout()
|
|
219
|
+
fig.savefig(outfile)
|
|
220
|
+
plt.close(fig)
|
|
221
|
+
return [(sig, s) for sig, _, s in items]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def null_comparison_plot(comparison, outfile, title=None, max_rows=25):
|
|
225
|
+
"""Grid of which surrogate nulls each pattern survives, so the DIFFERENCE reads
|
|
226
|
+
at a glance. One row per pattern (surviving under at least one null), one column
|
|
227
|
+
per null; a filled cell means the pattern survives that null, annotated with its
|
|
228
|
+
q-value. Patterns are grouped core-first, then each null's unique (shell) set.
|
|
229
|
+
|
|
230
|
+
`comparison` is a NullComparison from compare_nulls(). Returns the rows plotted.
|
|
231
|
+
"""
|
|
232
|
+
nulls = comparison.nulls
|
|
233
|
+
# order: core first, then each null's unique set, most significant within group
|
|
234
|
+
def _minq(sig):
|
|
235
|
+
return min((comparison._q(n, sig) or 1.0) for n in nulls)
|
|
236
|
+
ordered = ([s for s in sorted(comparison.common, key=_minq)]
|
|
237
|
+
+ [s for n in nulls for s in sorted(comparison.unique[n], key=_minq)])
|
|
238
|
+
seen = set()
|
|
239
|
+
ordered = [s for s in ordered if not (s in seen or seen.add(s))][:max_rows]
|
|
240
|
+
if not ordered:
|
|
241
|
+
fig, ax = plt.subplots(figsize=(6, 2), dpi=200)
|
|
242
|
+
ax.text(0.5, 0.5, "No pattern survived under any null.", ha="center", va="center")
|
|
243
|
+
ax.axis("off"); fig.savefig(outfile); plt.close(fig); return []
|
|
244
|
+
|
|
245
|
+
GREEN, GREY, EDGE = "#2f7d4f", "#e4e7ec", "#333"
|
|
246
|
+
nrow, ncol = len(ordered), len(nulls)
|
|
247
|
+
fig, ax = plt.subplots(figsize=(3.0 + 1.5 * ncol, 1.1 + 0.42 * nrow), dpi=200)
|
|
248
|
+
for r, sig in enumerate(ordered):
|
|
249
|
+
y = nrow - 1 - r
|
|
250
|
+
layer = comparison.layer(sig)
|
|
251
|
+
for c, n in enumerate(nulls):
|
|
252
|
+
surv = sig in comparison.survives[n]
|
|
253
|
+
ax.add_patch(plt.Rectangle((c, y), 0.92, 0.92, facecolor=GREEN if surv else GREY,
|
|
254
|
+
edgecolor=EDGE, lw=0.6))
|
|
255
|
+
q = comparison._q(n, sig)
|
|
256
|
+
if q is not None:
|
|
257
|
+
ax.text(c + 0.46, y + 0.46, f"{q:.3f}", ha="center", va="center",
|
|
258
|
+
fontsize=7.5, color="white" if surv else "#8a93a3",
|
|
259
|
+
fontweight="bold" if surv else "normal")
|
|
260
|
+
ax.text(ncol + 0.15, y + 0.46, f"{sig}", ha="left", va="center", fontsize=7.4)
|
|
261
|
+
ax.text(-0.15, y + 0.46, layer, ha="right", va="center", fontsize=6.8,
|
|
262
|
+
color="#5b6270", style="italic")
|
|
263
|
+
ax.set_xlim(-2.4, ncol + 6.5); ax.set_ylim(-0.3, nrow + 0.2)
|
|
264
|
+
for c, n in enumerate(nulls):
|
|
265
|
+
ax.text(c + 0.46, nrow + 0.02, n, ha="center", va="bottom", fontsize=9, fontweight="bold")
|
|
266
|
+
ax.axis("off")
|
|
267
|
+
if title:
|
|
268
|
+
ax.set_title(title, fontsize=10.5, fontweight="bold", loc="left")
|
|
269
|
+
fig.text(0.5, 0.005, "filled = survives that null (q shown); core = survives all nulls, "
|
|
270
|
+
"shell = survives one null only", ha="center", fontsize=6.8, color="#8a93a3")
|
|
271
|
+
fig.tight_layout(rect=(0, 0.03, 1, 1))
|
|
272
|
+
fig.savefig(outfile)
|
|
273
|
+
plt.close(fig)
|
|
274
|
+
return ordered
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tpattern
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Open, auditable temporal & sequential pattern analysis (T-patterns) for sport and behaviour
|
|
5
5
|
Author-email: Andrew Callaway <acallaway@bournemouth.ac.uk>
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/ajcallaway/TPattern
|
|
8
8
|
Project-URL: Repository, https://github.com/ajcallaway/TPattern
|
|
9
9
|
Project-URL: Archive (DOI), https://doi.org/10.5281/zenodo.21397543
|
|
@@ -15,7 +15,6 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
19
18
|
Classifier: Operating System :: OS Independent
|
|
20
19
|
Classifier: Intended Audience :: Science/Research
|
|
21
20
|
Classifier: Topic :: Scientific/Engineering
|
|
@@ -5,7 +5,9 @@ tests/test_calibrated_pins.py
|
|
|
5
5
|
tests/test_contrast.py
|
|
6
6
|
tests/test_dedup.py
|
|
7
7
|
tests/test_guided.py
|
|
8
|
+
tests/test_null_compare.py
|
|
8
9
|
tests/test_reproduction.py
|
|
10
|
+
tests/test_support_reporting.py
|
|
9
11
|
tests/test_synthetic.py
|
|
10
12
|
tpattern/__init__.py
|
|
11
13
|
tpattern/advisor.py
|
tpattern-0.1.2/tpattern/viz.py
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Visual output for detected T-patterns — clean, publication-ready figures.
|
|
3
|
-
|
|
4
|
-
The centrepiece is `pattern_dendrogram`: a T-pattern is a binary tree of
|
|
5
|
-
critical-interval links, and this draws it as a proper dendrogram — event types
|
|
6
|
-
as leaves, each internal join annotated with its critical interval [d1, d2] (the
|
|
7
|
-
time window within which the right side follows the left). THEME draws this as a
|
|
8
|
-
cramped detection tree; here it is a clean, labelled figure.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
from __future__ import annotations
|
|
12
|
-
|
|
13
|
-
import matplotlib
|
|
14
|
-
matplotlib.use("Agg")
|
|
15
|
-
import matplotlib.pyplot as plt
|
|
16
|
-
|
|
17
|
-
from .pattern import Pattern
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _layout(p: Pattern, leaves: list, pos: dict):
|
|
21
|
-
"""Assign (x, depth) to every node. Leaves get sequential x; internal nodes
|
|
22
|
-
sit at the mid-x of their children, depth = 1 + max child depth."""
|
|
23
|
-
if p.is_terminal:
|
|
24
|
-
x = len(leaves)
|
|
25
|
-
leaves.append(p.event)
|
|
26
|
-
pos[id(p)] = (x, 0)
|
|
27
|
-
return x, 0
|
|
28
|
-
lx, ld = _layout(p.left, leaves, pos)
|
|
29
|
-
rx, rd = _layout(p.right, leaves, pos)
|
|
30
|
-
x = (lx + rx) / 2.0
|
|
31
|
-
d = 1 + max(ld, rd)
|
|
32
|
-
pos[id(p)] = (x, d)
|
|
33
|
-
return x, d
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _draw(p: Pattern, pos: dict, ax, ci_unit: str):
|
|
37
|
-
"""Recursively draw dendrogram elbows from each internal node to its children."""
|
|
38
|
-
if p.is_terminal:
|
|
39
|
-
return
|
|
40
|
-
x, d = pos[id(p)]
|
|
41
|
-
for child in (p.left, p.right):
|
|
42
|
-
cx, cd = pos[id(child)]
|
|
43
|
-
# elbow: up from child to this node's depth, then across
|
|
44
|
-
ax.plot([cx, cx], [cd, d], color="#34495e", lw=1.4)
|
|
45
|
-
ax.plot([cx, x], [d, d], color="#34495e", lw=1.4)
|
|
46
|
-
_draw(child, pos, ax, ci_unit)
|
|
47
|
-
# annotate the critical interval on the join
|
|
48
|
-
if p.ci is not None:
|
|
49
|
-
d1, d2 = p.ci
|
|
50
|
-
ax.annotate(f"[{d1},{d2}]{ci_unit}", (x, d), textcoords="offset points",
|
|
51
|
-
xytext=(0, 4), ha="center", fontsize=7, color="#c0392b")
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def pattern_dendrogram(pattern: Pattern, title: str | None = None,
|
|
55
|
-
ci_unit: str = "", outfile: str | None = None, ax=None):
|
|
56
|
-
"""Draw one T-pattern as a dendrogram. Returns the Axes.
|
|
57
|
-
|
|
58
|
-
Leaves (event types) are labelled along the bottom in temporal order; each
|
|
59
|
-
internal join shows its critical interval. `pattern.N` and significance can
|
|
60
|
-
be put in the title.
|
|
61
|
-
"""
|
|
62
|
-
leaves: list = []
|
|
63
|
-
pos: dict = {}
|
|
64
|
-
_layout(pattern, leaves, pos)
|
|
65
|
-
|
|
66
|
-
own = ax is None
|
|
67
|
-
if own:
|
|
68
|
-
fig, ax = plt.subplots(figsize=(max(4, 1.1 * len(leaves)), 3.2))
|
|
69
|
-
_draw(pattern, pos, ax, ci_unit)
|
|
70
|
-
|
|
71
|
-
# leaf labels
|
|
72
|
-
for i, name in enumerate(leaves):
|
|
73
|
-
ax.plot([i], [0], "o", color="#2980b9", ms=6)
|
|
74
|
-
ax.annotate(name, (i, 0), textcoords="offset points", xytext=(0, -8),
|
|
75
|
-
ha="right", va="top", rotation=35, fontsize=8)
|
|
76
|
-
|
|
77
|
-
ax.set_xlim(-0.7, len(leaves) - 0.3)
|
|
78
|
-
ax.set_ylim(-1.2, pattern.level + 0.6)
|
|
79
|
-
ax.set_yticks(range(pattern.level + 1))
|
|
80
|
-
ax.set_ylabel("level")
|
|
81
|
-
ax.set_xticks([])
|
|
82
|
-
for s in ("top", "right", "bottom"):
|
|
83
|
-
ax.spines[s].set_visible(False)
|
|
84
|
-
if title:
|
|
85
|
-
ax.set_title(title, fontsize=9, wrap=True)
|
|
86
|
-
|
|
87
|
-
if own and outfile:
|
|
88
|
-
plt.tight_layout(); plt.savefig(outfile, dpi=150); plt.close()
|
|
89
|
-
return ax
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def patterns_overview(patterns: list, outfile: str, max_rows: int = 8,
|
|
93
|
-
ci_unit: str = "", by=lambda p: -p.N):
|
|
94
|
-
"""A grid of the top pattern dendrograms (by `by`, default most frequent)."""
|
|
95
|
-
comps = sorted([p for p in patterns if p.level >= 1], key=by)[:max_rows]
|
|
96
|
-
n = len(comps)
|
|
97
|
-
if not n:
|
|
98
|
-
return
|
|
99
|
-
cols = 2
|
|
100
|
-
rows = (n + cols - 1) // cols
|
|
101
|
-
fig, axes = plt.subplots(rows, cols, figsize=(12, 3.0 * rows))
|
|
102
|
-
axes = axes.flatten() if n > 1 else [axes]
|
|
103
|
-
for ax, p in zip(axes, comps):
|
|
104
|
-
pattern_dendrogram(p, title=f"N={p.N}, level {p.level}", ci_unit=ci_unit, ax=ax)
|
|
105
|
-
for ax in axes[n:]:
|
|
106
|
-
ax.axis("off")
|
|
107
|
-
plt.tight_layout(); plt.savefig(outfile, dpi=150); plt.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|