tpattern 0.1.1__tar.gz → 0.1.4__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.1/tpattern.egg-info → tpattern-0.1.4}/PKG-INFO +2 -3
- {tpattern-0.1.1 → tpattern-0.1.4}/pyproject.toml +4 -4
- {tpattern-0.1.1 → tpattern-0.1.4}/tests/test_dedup.py +14 -0
- tpattern-0.1.4/tests/test_support_reporting.py +84 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern/__init__.py +4 -4
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern/advisor.py +63 -1
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern/detect.py +84 -12
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern/methods.py +29 -1
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern/report.py +15 -1
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern/significance.py +11 -3
- tpattern-0.1.4/tpattern/viz.py +221 -0
- {tpattern-0.1.1 → tpattern-0.1.4/tpattern.egg-info}/PKG-INFO +2 -3
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern.egg-info/SOURCES.txt +1 -0
- tpattern-0.1.1/tpattern/viz.py +0 -107
- {tpattern-0.1.1 → tpattern-0.1.4}/LICENSE +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/README.md +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/setup.cfg +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tests/test_calibrated_pins.py +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tests/test_contrast.py +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tests/test_guided.py +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tests/test_reproduction.py +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tests/test_synthetic.py +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern/ci.py +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern/contrast.py +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern/diagnostics.py +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern/guided.py +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern/io.py +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern/pattern.py +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern/randomise.py +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern/synthetic.py +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern.egg-info/dependency_links.txt +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/tpattern.egg-info/requires.txt +0 -0
- {tpattern-0.1.1 → tpattern-0.1.4}/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.4
|
|
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.4"
|
|
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,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,6 +6,8 @@ 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.4" # 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
|
|
@@ -13,14 +15,12 @@ from .detect import Config, Engine
|
|
|
13
15
|
from .randomise import run_null, NullResult, rotate, shuffle
|
|
14
16
|
from .significance import calibrate, CalibrationResult
|
|
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
|
|
17
19
|
from .advisor import recommend
|
|
18
20
|
from .methods import methods_text
|
|
19
21
|
from .guided import run_analysis, launch
|
|
20
22
|
from .contrast import group_contrast, contrast_items
|
|
21
23
|
|
|
22
|
-
__version__ = "0.1.1"
|
|
23
|
-
|
|
24
24
|
__all__ = [
|
|
25
25
|
"Observation", "read_observation", "read_sample", "read_table",
|
|
26
26
|
"Instance", "Pattern",
|
|
@@ -29,7 +29,7 @@ __all__ = [
|
|
|
29
29
|
"run_null", "NullResult", "rotate", "shuffle",
|
|
30
30
|
"calibrate", "CalibrationResult",
|
|
31
31
|
"patterns_table", "forest_plot", "report",
|
|
32
|
-
"pattern_dendrogram", "patterns_overview",
|
|
32
|
+
"pattern_dendrogram", "patterns_overview", "support_profile",
|
|
33
33
|
"recommend", "methods_text",
|
|
34
34
|
"run_analysis", "launch",
|
|
35
35
|
"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:
|
|
@@ -237,5 +240,64 @@ def recommend(observations, *, min_count: int = 15,
|
|
|
237
240
|
evidence={"threshold": threshold, "frequent": {}},
|
|
238
241
|
)
|
|
239
242
|
|
|
243
|
+
# --- Minimum occurrence (detection floor; matches Config.min_occurrence) ---
|
|
244
|
+
min_occ_choice = Choice(
|
|
245
|
+
"Minimum occurrence", "3 (the field floor)",
|
|
246
|
+
rationale=("A pattern is kept only if it recurs at least this many times (default 3, the "
|
|
247
|
+
"T-pattern convention); below three occurrences the occurrence-based surrogate "
|
|
248
|
+
"test is too coarse to calibrate. Impact of raising it: fewer but "
|
|
249
|
+
"better-supported patterns — it removes the near-threshold tail (which "
|
|
250
|
+
"calibration discards anyway) and shrinks the multiple-comparison family, at "
|
|
251
|
+
"the cost of missing genuinely rare structure. Lowering it below three is not "
|
|
252
|
+
"recommended."),
|
|
253
|
+
plain=("<b>How many times a chain must repeat to count.</b> The default is three. "
|
|
254
|
+
"<i>Raise it</i> to report only patterns that recur often and drop rare, borderline "
|
|
255
|
+
"ones; <i>lowering</i> is not advised — two or three occurrences is already the "
|
|
256
|
+
"smallest number the chance test can judge."),
|
|
257
|
+
evidence={"min_occurrence": 3, "n_obs": n_obs},
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# --- Number of surrogates B (Monte-Carlo resolution; matches calibrate's B) ---
|
|
261
|
+
B_choice = Choice(
|
|
262
|
+
"Number of surrogates (B)", "200 to screen; thousands to confirm one pattern",
|
|
263
|
+
rationale=("The Monte-Carlo empirical p-value cannot fall below 1/(B+1), so B sets the "
|
|
264
|
+
"finest significance any single pattern can reach. B=200 is ample for "
|
|
265
|
+
"false-discovery screening; a family-wise confirmatory claim needs 1/(B+1) "
|
|
266
|
+
"below the family threshold alpha/m — i.e. B greater than m/alpha — which for a "
|
|
267
|
+
"typical pattern family is in the thousands. Impact of raising B: a lower "
|
|
268
|
+
"p-floor and a smoother empirical p, at a runtime cost that grows linearly in "
|
|
269
|
+
"B; too small a B silently caps significance and can leave a real pattern "
|
|
270
|
+
"unconfirmable."),
|
|
271
|
+
plain=("<b>How many reshuffled datasets to test against.</b> More surrogates give a finer, "
|
|
272
|
+
"more trustworthy p-value but are proportionally slower. <i>Use about 200</i> to "
|
|
273
|
+
"screen many patterns; <i>raise into the thousands</i> to confirm one specific "
|
|
274
|
+
"pattern, because the smallest possible p-value is 1 divided by (B+1)."),
|
|
275
|
+
evidence={"floor_at_200": 1/201, "floor_at_2000": 1/2001},
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# --- diagnostic (reported, not recommended): same-instant duplicate records ---
|
|
279
|
+
notes = []
|
|
280
|
+
n_dup = 0
|
|
281
|
+
for o in observations:
|
|
282
|
+
seen = set()
|
|
283
|
+
for t, ev in o.events:
|
|
284
|
+
if (ev, t) in seen:
|
|
285
|
+
n_dup += 1
|
|
286
|
+
else:
|
|
287
|
+
seen.add((ev, t))
|
|
288
|
+
if n_dup:
|
|
289
|
+
res = _resolution_stats(observations)
|
|
290
|
+
gap = res.get("min_nonzero_gap")
|
|
291
|
+
coarse = (f" Your finest inter-event gap is {gap} time units, so if that resolution is "
|
|
292
|
+
f"coarse for your data these may be genuinely distinct events; set "
|
|
293
|
+
f"collapse_duplicates=False to keep them.") if gap and gap > 1 else ""
|
|
294
|
+
notes.append(
|
|
295
|
+
f"{n_dup} same-instant duplicate record(s) (same event type at an identical timestamp) "
|
|
296
|
+
f"were found and are collapsed to one point by default (collapse_duplicates): two records "
|
|
297
|
+
f"at the same instant occupy one point and retaining both would inflate that type's "
|
|
298
|
+
f"baseline rate.{coarse}")
|
|
299
|
+
|
|
240
300
|
return Recommendation(n_obs=n_obs, n_events=n_events,
|
|
241
|
-
choices=[freq_choice, null_choice, lag_choice,
|
|
301
|
+
choices=[freq_choice, min_occ_choice, null_choice, lag_choice,
|
|
302
|
+
err_choice, B_choice],
|
|
303
|
+
notes=notes)
|
|
@@ -42,17 +42,44 @@ from .pattern import Instance, Pattern
|
|
|
42
42
|
@dataclass
|
|
43
43
|
class Config:
|
|
44
44
|
"""Detection settings. The defaults reproduce THEME's parameters, so most users
|
|
45
|
-
change only a couple of things
|
|
45
|
+
change only a couple of things. Every field below is user-editable; each carries
|
|
46
|
+
a mathematical reason to change it and a consequence if you do — stated here and,
|
|
47
|
+
for the data-driven ones, advised from your data by ``advisor.recommend``.
|
|
48
|
+
|
|
49
|
+
Data-driven (let ``advisor.recommend(observations)`` set these, with reasons):
|
|
46
50
|
|
|
47
51
|
* ``min_lag`` — set to 1 on frame-coded data (video timestamps), where events
|
|
48
52
|
sharing a timestamp are co-occurrence, not sequence. Default 0 = THEME's
|
|
49
|
-
behaviour
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
behaviour. Impact: at 0, co-timed events read as directional sequences
|
|
54
|
+
(Δt = 0 has no defined order → spurious patterns); at 1 they are reported as
|
|
55
|
+
co-occurrence instead.
|
|
56
|
+
* ``freq_exclude`` — mean occurrences per observation above which a type is
|
|
57
|
+
barred from *building* patterns. Impact: raise/disable to keep a near-ubiquitous
|
|
58
|
+
connector (risking combinatorial growth of the hierarchy); lower to exclude more
|
|
59
|
+
types. The excluded type is still counted at Level 0.
|
|
60
|
+
* ``min_occurrence`` — fewest times a chain must recur to be kept. Impact: below
|
|
61
|
+
~3 the occurrence test is too coarse to calibrate; raise it to drop the rare,
|
|
62
|
+
near-threshold tail and shrink the multiple-comparison family.
|
|
63
|
+
|
|
64
|
+
Documented defaults (sensible as-is; change only with a specific reason):
|
|
65
|
+
|
|
66
|
+
* ``alpha`` — threshold on the critical-interval binomial-tail p that accepts a
|
|
67
|
+
time window. Impact: lower → tighter, fewer windows; higher → more candidate
|
|
68
|
+
structure. Final significance comes from ``calibrate``, so this mainly ranks
|
|
69
|
+
strength, not truth.
|
|
70
|
+
* ``lumping_factor`` — treat B as part of A (and drop it from further search)
|
|
71
|
+
when the forward conditional prob N_AB/N_A exceeds this. Impact: set 0.7–1.0 on
|
|
72
|
+
dense data to bound combinatorial blow-up; None (default) keeps every pair
|
|
73
|
+
separate.
|
|
74
|
+
* ``min_samples_frac`` — a pattern must occur in at least this fraction of
|
|
75
|
+
observations (anti-monotone support; pruned during the search). Impact: set
|
|
76
|
+
(e.g. 0.1) to require patterns be widespread rather than one-offs.
|
|
77
|
+
* ``exclude_events`` — explicit include/exclude list; overrides the frequency
|
|
78
|
+
rule. Impact: force-keep a central event or force-drop a nuisance one by hand;
|
|
79
|
+
``[]`` disables exclusion entirely.
|
|
80
|
+
|
|
81
|
+
The null and the error-rate control (FDR q-target, family-wise α) are set at
|
|
82
|
+
``calibrate`` — see its docstring for their maths and impact.
|
|
56
83
|
"""
|
|
57
84
|
|
|
58
85
|
alpha: float = 0.005 # Significance Level
|
|
@@ -63,11 +90,27 @@ class Config:
|
|
|
63
90
|
# a genuine temporal lag, so same-timestamp
|
|
64
91
|
# co-occurrences are excluded and handled
|
|
65
92
|
# separately as "simultaneity".
|
|
66
|
-
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.
|
|
67
103
|
exclude_events: list[str] | None = None # explicit exclusion list (overrides
|
|
68
104
|
# the frequency rule when provided;
|
|
69
105
|
# [] disables exclusion entirely)
|
|
70
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.)
|
|
71
114
|
completeness: bool = True # apply completeness competition
|
|
72
115
|
collapse_equivalent: bool = True # collapse occurrence-identical patterns (the
|
|
73
116
|
# two directions of a co-timed pair, or different bracketings of one chain)
|
|
@@ -85,6 +128,25 @@ class Config:
|
|
|
85
128
|
# so it is pruned during the search.
|
|
86
129
|
max_level: int = 8 # safety cap on hierarchy depth
|
|
87
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
|
+
|
|
88
150
|
def is_excluded(self, event: str, mean_per_obs: float) -> bool:
|
|
89
151
|
"""Decide whether an event type is barred from *building* patterns.
|
|
90
152
|
|
|
@@ -135,18 +197,28 @@ class Engine:
|
|
|
135
197
|
by_type: dict[str, list[Instance]] = defaultdict(list)
|
|
136
198
|
token = 0
|
|
137
199
|
for i, o in enumerate(self.obs):
|
|
200
|
+
seen_pt: set[tuple[str, int]] = set() # (type, time) points seen in this obs
|
|
138
201
|
for t, ev in o.events:
|
|
139
|
-
|
|
140
|
-
|
|
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.
|
|
141
211
|
by_type[ev].append(Instance(obs=i, start=t, end=t, tokens=frozenset((token,))))
|
|
142
212
|
token += 1
|
|
143
213
|
|
|
144
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)
|
|
145
217
|
self.univariate, self.terminals, self.excluded = [], [], []
|
|
146
218
|
for ev, insts in sorted(by_type.items()):
|
|
147
219
|
term = Pattern(event=ev, instances=insts)
|
|
148
220
|
self.univariate.append(term)
|
|
149
|
-
if
|
|
221
|
+
if ev in excluded:
|
|
150
222
|
self.excluded.append(ev)
|
|
151
223
|
continue
|
|
152
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":
|
|
@@ -134,10 +135,16 @@ def calibrate(observations, config: Config | None = None, *,
|
|
|
134
135
|
"rotation"/"shuffle" are THEME-style nulls.
|
|
135
136
|
B : int
|
|
136
137
|
Number of surrogate datasets. The empirical p-value cannot fall below
|
|
137
|
-
1/(B+1), so family-wise (FWER) claims need B
|
|
138
|
-
at B=200.
|
|
138
|
+
1/(B+1), so family-wise (FWER) claims need B > m/alpha (m = patterns tested,
|
|
139
|
+
typically thousands); FDR is fine at B=200. Impact: too small silently caps
|
|
140
|
+
significance — a genuine pattern cannot clear the correction floor; larger B
|
|
141
|
+
gives a finer p at a runtime cost linear in B.
|
|
139
142
|
alpha, q_target : float
|
|
140
143
|
Family-wise level (Holm) and false-discovery target (Benjamini-Hochberg).
|
|
144
|
+
Impact: lower q_target → fewer, safer FDR survivors; lower alpha → very few
|
|
145
|
+
confirmatory FWER patterns (raise alpha to 0.05 for the conventional bar).
|
|
146
|
+
FDR flags more (a few flukes) and is for screening; FWER flags fewer, very
|
|
147
|
+
safely, and is for a single confirmatory claim.
|
|
141
148
|
seed : int
|
|
142
149
|
Fixes the surrogate draw, so results are reproducible.
|
|
143
150
|
|
|
@@ -215,4 +222,5 @@ def calibrate(observations, config: Config | None = None, *,
|
|
|
215
222
|
c.fdr_q = qmap[id(c)]
|
|
216
223
|
|
|
217
224
|
return CalibrationResult(real=real, surrogate_counts=surrogate_counts,
|
|
218
|
-
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))
|
|
@@ -0,0 +1,221 @@
|
|
|
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]
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tpattern
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
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
|
tpattern-0.1.1/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
|