tpattern 0.1.2__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.
Files changed (33) hide show
  1. {tpattern-0.1.2/tpattern.egg-info → tpattern-0.1.4}/PKG-INFO +2 -3
  2. {tpattern-0.1.2 → tpattern-0.1.4}/pyproject.toml +4 -4
  3. {tpattern-0.1.2 → tpattern-0.1.4}/tests/test_dedup.py +14 -0
  4. tpattern-0.1.4/tests/test_support_reporting.py +84 -0
  5. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern/__init__.py +4 -4
  6. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern/advisor.py +27 -1
  7. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern/detect.py +49 -4
  8. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern/methods.py +29 -1
  9. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern/report.py +15 -1
  10. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern/significance.py +3 -1
  11. tpattern-0.1.4/tpattern/viz.py +221 -0
  12. {tpattern-0.1.2 → tpattern-0.1.4/tpattern.egg-info}/PKG-INFO +2 -3
  13. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern.egg-info/SOURCES.txt +1 -0
  14. tpattern-0.1.2/tpattern/viz.py +0 -107
  15. {tpattern-0.1.2 → tpattern-0.1.4}/LICENSE +0 -0
  16. {tpattern-0.1.2 → tpattern-0.1.4}/README.md +0 -0
  17. {tpattern-0.1.2 → tpattern-0.1.4}/setup.cfg +0 -0
  18. {tpattern-0.1.2 → tpattern-0.1.4}/tests/test_calibrated_pins.py +0 -0
  19. {tpattern-0.1.2 → tpattern-0.1.4}/tests/test_contrast.py +0 -0
  20. {tpattern-0.1.2 → tpattern-0.1.4}/tests/test_guided.py +0 -0
  21. {tpattern-0.1.2 → tpattern-0.1.4}/tests/test_reproduction.py +0 -0
  22. {tpattern-0.1.2 → tpattern-0.1.4}/tests/test_synthetic.py +0 -0
  23. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern/ci.py +0 -0
  24. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern/contrast.py +0 -0
  25. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern/diagnostics.py +0 -0
  26. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern/guided.py +0 -0
  27. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern/io.py +0 -0
  28. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern/pattern.py +0 -0
  29. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern/randomise.py +0 -0
  30. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern/synthetic.py +0 -0
  31. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern.egg-info/dependency_links.txt +0 -0
  32. {tpattern-0.1.2 → tpattern-0.1.4}/tpattern.egg-info/requires.txt +0 -0
  33. {tpattern-0.1.2 → 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.2
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>=61"]
2
+ requires = ["setuptools>=77"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tpattern"
7
- version = "0.1.2"
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 = { text = "MIT" }
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.2"
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:
@@ -272,6 +275,29 @@ def recommend(observations, *, min_count: int = 15,
272
275
  evidence={"floor_at_200": 1/201, "floor_at_2000": 1/2001},
273
276
  )
274
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
+
275
300
  return Recommendation(n_obs=n_obs, n_events=n_events,
276
301
  choices=[freq_choice, min_occ_choice, null_choice, lag_choice,
277
- err_choice, B_choice])
302
+ err_choice, B_choice],
303
+ 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 if mean/obs exceeds this
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
- # Every raw event occurrence gets a globally-unique token id so
167
- # that composite patterns can never reuse the same event twice.
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 self.cfg.is_excluded(ev, len(insts) / n_obs):
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 = "0.1.0"
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,5 @@ 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))
@@ -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.2
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
@@ -6,6 +6,7 @@ tests/test_contrast.py
6
6
  tests/test_dedup.py
7
7
  tests/test_guided.py
8
8
  tests/test_reproduction.py
9
+ tests/test_support_reporting.py
9
10
  tests/test_synthetic.py
10
11
  tpattern/__init__.py
11
12
  tpattern/advisor.py
@@ -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