tpattern 0.1.10__tar.gz → 0.1.11__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.10/tpattern.egg-info → tpattern-0.1.11}/PKG-INFO +1 -1
- {tpattern-0.1.10 → tpattern-0.1.11}/pyproject.toml +1 -1
- tpattern-0.1.11/tests/test_outputs.py +86 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/__init__.py +8 -4
- tpattern-0.1.11/tpattern/report.py +405 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/viz.py +126 -6
- {tpattern-0.1.10 → tpattern-0.1.11/tpattern.egg-info}/PKG-INFO +1 -1
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern.egg-info/SOURCES.txt +1 -0
- tpattern-0.1.10/tpattern/report.py +0 -220
- {tpattern-0.1.10 → tpattern-0.1.11}/LICENSE +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/README.md +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/setup.cfg +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_calibrated_pins.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_contrast.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_dedup.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_guided.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_null_compare.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_read_table_validation.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_report_dendrogram.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_reproduction.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_sofcoder.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_support_reporting.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_synthetic.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/advisor.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/ci.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/contrast.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/detect.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/diagnostics.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/guided.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/io.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/methods.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/pattern.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/randomise.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/significance.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/synthetic.py +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern.egg-info/dependency_links.txt +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern.egg-info/requires.txt +0 -0
- {tpattern-0.1.10 → tpattern-0.1.11}/tpattern.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tpattern"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.11"
|
|
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"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Tests for the 0.1.11 output layer: per-pattern timing, and the THEME-style
|
|
2
|
+
tables (durations, event-type roles, connections, data summary, interval counts)."""
|
|
3
|
+
import os
|
|
4
|
+
from tpattern import (Observation, Engine, Config, calibrate, patterns_table,
|
|
5
|
+
duration_stats, pattern_durations, event_type_roles,
|
|
6
|
+
connections, data_summary, interval_counts, report,
|
|
7
|
+
occurrence_plot, duration_plot)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def sample():
|
|
11
|
+
# A -> B at a fixed 300 ms lag in every observation; half also carry a later C, so
|
|
12
|
+
# (A B) survives standalone (not fully absorbed) alongside the longer ((A B) C).
|
|
13
|
+
obs = []
|
|
14
|
+
for i in range(8):
|
|
15
|
+
ev = [(0, "A"), (300, "B")]
|
|
16
|
+
if i < 4:
|
|
17
|
+
ev.append((600, "C"))
|
|
18
|
+
obs.append(Observation(name=f"o{i}", start=0, end=1000, events=ev))
|
|
19
|
+
return obs
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def patterns():
|
|
23
|
+
return Engine(sample(), Config(min_lag=1)).detect()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_duration_stats_from_instances():
|
|
27
|
+
p = next(p for p in patterns() if p.signature() == "(A B)")
|
|
28
|
+
d = duration_stats(p)
|
|
29
|
+
assert d["n"] == 8 and d["median"] == 300 and d["min"] == 300 and d["max"] == 300
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_patterns_table_has_timing_columns():
|
|
33
|
+
rows = patterns_table(patterns(), n_observations=8)
|
|
34
|
+
ab = next(r for r in rows if r["signature"] == "(A B)")
|
|
35
|
+
assert ab["duration_median_ms"] == 300
|
|
36
|
+
assert ab["critical_interval"].endswith("ms") # unit is labelled
|
|
37
|
+
assert ab["duration_range_ms"] == "[300,300]"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_pattern_durations_one_row_per_occurrence():
|
|
41
|
+
rows = pattern_durations(patterns())
|
|
42
|
+
ab = [r for r in rows if r["signature"] == "(A B)"]
|
|
43
|
+
assert len(ab) == 8
|
|
44
|
+
assert all(r["duration_ms"] == r["end_ms"] - r["start_ms"] for r in rows)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_event_type_roles_first_and_last():
|
|
48
|
+
roles = {r["event_type"]: r for r in event_type_roles(patterns())}
|
|
49
|
+
# A only ever starts a pattern; C only ever ends one
|
|
50
|
+
assert roles["A"]["pct_first"] == 1.0 and roles["A"]["as_last"] == 0
|
|
51
|
+
assert roles["C"]["pct_last"] == 1.0 and roles["C"]["as_first"] == 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_connections_direction():
|
|
55
|
+
conn = {r["event_type"]: r for r in connections(patterns())}
|
|
56
|
+
assert "B" in conn["A"]["followed_by"] and "C" in conn["A"]["followed_by"]
|
|
57
|
+
assert conn["A"]["n_followed_by"] >= 2
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_data_summary_counts():
|
|
61
|
+
rows = data_summary(sample())
|
|
62
|
+
a = next(r for r in rows if r["event_type"] == "A")
|
|
63
|
+
assert a["count"] == 8 and a["observations"] == 8 and a["mean_per_observation"] == 1.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_interval_counts_split():
|
|
67
|
+
rows = interval_counts(patterns(), [(0, 100), (100, 1000)], split_time=100)
|
|
68
|
+
ab = next(r for r in rows if r["signature"] == "(A B)")
|
|
69
|
+
# every A->B occurrence starts at t=0, so all are in [0,100] and before the split
|
|
70
|
+
assert ab["in[0,100]"] == 8 and ab["before_split"] == 8 and ab["after_split"] == 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_report_writes_all_default_outputs(tmp_path):
|
|
74
|
+
res = calibrate(sample(), Config(min_lag=1), null="profile", B=50, q_target=0.05, seed=1)
|
|
75
|
+
w = report(res, str(tmp_path), observations=sample(), title="unit test")
|
|
76
|
+
for key in ("table", "durations", "roles", "connections", "data_summary",
|
|
77
|
+
"dendrograms", "durations_plot", "occurrence_plot", "summary"):
|
|
78
|
+
assert key in w and os.path.getsize(w[key]) > 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_plots_render(tmp_path):
|
|
82
|
+
res = calibrate(sample(), Config(min_lag=1), null="profile", B=50, q_target=0.05, seed=1)
|
|
83
|
+
f1 = str(tmp_path / "occ.png"); f2 = str(tmp_path / "dur.png")
|
|
84
|
+
occurrence_plot(sample(), f1)
|
|
85
|
+
duration_plot(res, f2)
|
|
86
|
+
assert os.path.getsize(f1) > 0 and os.path.getsize(f2) > 0
|
|
@@ -6,7 +6,7 @@ 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.
|
|
9
|
+
__version__ = "0.1.11" # defined before submodule imports (methods.py reads it)
|
|
10
10
|
|
|
11
11
|
from .io import (Observation, read_observation, read_sample, read_table,
|
|
12
12
|
read_sofcoder, DataFormatError, check_reference, CHECKS)
|
|
@@ -15,9 +15,11 @@ from .ci import find_critical_interval, CIResult
|
|
|
15
15
|
from .detect import Config, Engine
|
|
16
16
|
from .randomise import run_null, NullResult, rotate, shuffle
|
|
17
17
|
from .significance import calibrate, CalibrationResult, compare_nulls, NullComparison
|
|
18
|
-
from .report import patterns_table, forest_plot, report
|
|
18
|
+
from .report import (patterns_table, forest_plot, report, interpret, duration_stats,
|
|
19
|
+
pattern_durations, event_type_roles, connections, data_summary,
|
|
20
|
+
interval_counts)
|
|
19
21
|
from .viz import (pattern_dendrogram, patterns_overview, support_profile,
|
|
20
|
-
null_comparison_plot)
|
|
22
|
+
null_comparison_plot, occurrence_plot, duration_plot, STYLE)
|
|
21
23
|
from .advisor import recommend
|
|
22
24
|
from .methods import methods_text
|
|
23
25
|
from .guided import run_analysis, launch
|
|
@@ -31,7 +33,9 @@ __all__ = [
|
|
|
31
33
|
"Config", "Engine",
|
|
32
34
|
"run_null", "NullResult", "rotate", "shuffle",
|
|
33
35
|
"calibrate", "CalibrationResult",
|
|
34
|
-
"patterns_table", "forest_plot", "report",
|
|
36
|
+
"patterns_table", "forest_plot", "report", "interpret", "duration_stats",
|
|
37
|
+
"pattern_durations", "event_type_roles", "connections", "data_summary",
|
|
38
|
+
"interval_counts", "occurrence_plot", "duration_plot", "STYLE",
|
|
35
39
|
"pattern_dendrogram", "patterns_overview", "support_profile",
|
|
36
40
|
"recommend", "methods_text",
|
|
37
41
|
"run_analysis", "launch",
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reporting — the output layer THEME does poorly.
|
|
3
|
+
|
|
4
|
+
Three things:
|
|
5
|
+
|
|
6
|
+
1. `patterns_table` — a tidy results table for detected patterns: the pattern
|
|
7
|
+
string, N, length, level, loop flag, critical interval, and (when a
|
|
8
|
+
calibration is supplied) the surrogate empirical p with **repeated-testing
|
|
9
|
+
correction** (Benjamini–Hochberg q for screening, Holm/FWER keep for
|
|
10
|
+
confirmation). Writes CSV and returns the rows.
|
|
11
|
+
|
|
12
|
+
2. `forest_plot` — effect sizes with confidence intervals (odds ratios from the
|
|
13
|
+
group/outcome contrasts) as a forest plot, the standard way to show
|
|
14
|
+
which contrasts matter and how uncertain they are.
|
|
15
|
+
|
|
16
|
+
3. `report` — one call that writes the table, the top-pattern dendrograms and (if
|
|
17
|
+
given) the forest plot into an output folder, so a detection run yields a
|
|
18
|
+
ready-to-read report.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import csv
|
|
24
|
+
import math
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
import matplotlib
|
|
28
|
+
matplotlib.use("Agg")
|
|
29
|
+
import matplotlib.pyplot as plt
|
|
30
|
+
from matplotlib.transforms import blended_transform_factory
|
|
31
|
+
|
|
32
|
+
import statistics as _stat
|
|
33
|
+
|
|
34
|
+
from .pattern import Pattern
|
|
35
|
+
from .significance import CalibrationResult, Calibrated
|
|
36
|
+
from .viz import patterns_overview
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --------------------------------------------------------------- timing / duration
|
|
40
|
+
# Every pattern occurrence has a start (first event) and end (last event); their
|
|
41
|
+
# difference is the occurrence's duration — for a two-event pattern that is the lag
|
|
42
|
+
# from the first event to the second. THEME exposes these only as an on-demand
|
|
43
|
+
# "Pattern Duration" export; here they are summarised (median and middle half) and
|
|
44
|
+
# reported by default, for every pattern, so the temporal content of a T-pattern is
|
|
45
|
+
# not left buried. All times are internal milliseconds (hence the `ms` unit).
|
|
46
|
+
def duration_stats(pattern):
|
|
47
|
+
"""Summary of a pattern's occurrence durations (end - start), or None if it has
|
|
48
|
+
no occurrences. Returns n, median, mean, q1, q3, min, max — all in ms."""
|
|
49
|
+
ds = sorted(i.end - i.start for i in pattern.instances)
|
|
50
|
+
if not ds:
|
|
51
|
+
return None
|
|
52
|
+
if len(ds) >= 4:
|
|
53
|
+
q1, med, q3 = (round(x) for x in _stat.quantiles(ds, n=4))
|
|
54
|
+
else:
|
|
55
|
+
q1, med, q3 = ds[0], round(_stat.median(ds)), ds[-1]
|
|
56
|
+
return {"n": len(ds), "median": round(_stat.median(ds)), "mean": round(_stat.mean(ds)),
|
|
57
|
+
"q1": q1, "q3": q3, "min": ds[0], "max": ds[-1]}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fmt_ci(ci, unit):
|
|
61
|
+
if not ci:
|
|
62
|
+
return ""
|
|
63
|
+
return f"[{ci[0]},{ci[1]}]{unit}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def interpret(pattern, N, fdr_q, *, q_target=0.05, ci_unit="ms", dur=None):
|
|
67
|
+
"""A plain-English reading of one calibrated pattern — what it is, whether it is
|
|
68
|
+
trustworthy, and its observed timing. Used as the results table's
|
|
69
|
+
`interpretation` column so a non-specialist can read the output directly.
|
|
70
|
+
|
|
71
|
+
`dur` is the pattern's :func:`duration_stats`; when given, the typical timing is
|
|
72
|
+
stated in words (median and middle half), which is the temporal content THEME's
|
|
73
|
+
output leaves out.
|
|
74
|
+
"""
|
|
75
|
+
if fdr_q <= q_target:
|
|
76
|
+
verdict, meaning = (f"Robust (survives FDR, q={fdr_q:.3g})",
|
|
77
|
+
"a genuine recurring sequence")
|
|
78
|
+
elif fdr_q <= 2 * q_target:
|
|
79
|
+
verdict, meaning = (f"Borderline (q={fdr_q:.3g})",
|
|
80
|
+
"near the significance threshold — treat with caution and "
|
|
81
|
+
"re-check with more surrogates")
|
|
82
|
+
else:
|
|
83
|
+
verdict, meaning = (f"Not significant after correction (q={fdr_q:.3g})",
|
|
84
|
+
"consistent with chance")
|
|
85
|
+
timing = ""
|
|
86
|
+
if dur and dur["median"]:
|
|
87
|
+
lead = ("the later event follows the earlier by a median of "
|
|
88
|
+
if pattern.length == 2 else "the pattern spans a median of ")
|
|
89
|
+
timing = (f"; {lead}{dur['median']} {ci_unit} "
|
|
90
|
+
f"(middle half {dur['q1']}–{dur['q3']}, range {dur['min']}–{dur['max']} {ci_unit})")
|
|
91
|
+
elif pattern.ci and not pattern.is_terminal:
|
|
92
|
+
d1, d2 = pattern.ci
|
|
93
|
+
timing = (f"; its components are linked within {d1}–{d2} {ci_unit}"
|
|
94
|
+
if d1 != d2 else f"; components co-occur (0 {ci_unit} apart)")
|
|
95
|
+
loop = " (a recycling loop: a repeated event type)" if pattern.has_loop else ""
|
|
96
|
+
plural = "occurrence" if N == 1 else "occurrences"
|
|
97
|
+
return f"{verdict}: {meaning}, {N} {plural}{timing}{loop}."
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def patterns_table(source, outfile: str | None = None, ci_unit: str = "ms",
|
|
101
|
+
sort: str = "auto", n_observations: int = 0):
|
|
102
|
+
"""Build a results table from a list of `Pattern` or a `CalibrationResult`.
|
|
103
|
+
|
|
104
|
+
Returns a list of row dicts. If `outfile` is given, also writes CSV.
|
|
105
|
+
When given a CalibrationResult, includes p_emp, fdr_q, fwer_keep and sorts by
|
|
106
|
+
p_emp; otherwise sorts by N (descending).
|
|
107
|
+
|
|
108
|
+
Every row carries `bouts` (distinct observations the pattern spans) and
|
|
109
|
+
`bout_support` (that as a fraction of the sample). Prevalence is reported rather
|
|
110
|
+
than filtered on: a reader can apply whatever support threshold they consider
|
|
111
|
+
appropriate, which a pre-applied cut-off would have denied them. Pass
|
|
112
|
+
`n_observations` to get support when `source` is a plain list of patterns.
|
|
113
|
+
"""
|
|
114
|
+
calibrated = isinstance(source, CalibrationResult)
|
|
115
|
+
items = source.real if calibrated else [p for p in source if p.level >= 1]
|
|
116
|
+
n_obs = n_observations or (source.n_observations if calibrated else 0)
|
|
117
|
+
|
|
118
|
+
rows = []
|
|
119
|
+
for it in items:
|
|
120
|
+
p = it.pattern if calibrated else it
|
|
121
|
+
# Distinct observations (bouts) the pattern spans. N counts occurrences and
|
|
122
|
+
# can exceed this, because one bout may contain the pattern several times.
|
|
123
|
+
# Reported so prevalence is visible as a number: a reader can then apply any
|
|
124
|
+
# support threshold they wish, rather than trusting one applied for them.
|
|
125
|
+
bouts = len({inst.obs for inst in p.instances})
|
|
126
|
+
dur = duration_stats(p)
|
|
127
|
+
row = {
|
|
128
|
+
"pattern": str(p),
|
|
129
|
+
"signature": p.signature(),
|
|
130
|
+
"N": p.N,
|
|
131
|
+
"bouts": bouts,
|
|
132
|
+
"bout_support": round(bouts / n_obs, 4) if n_obs else None,
|
|
133
|
+
"length": p.length,
|
|
134
|
+
"level": p.level,
|
|
135
|
+
"loop": int(p.has_loop),
|
|
136
|
+
"critical_interval": _fmt_ci(p.ci, ci_unit),
|
|
137
|
+
# observed timing of the occurrences (THEME's Pattern Duration, summarised
|
|
138
|
+
# and reported by default rather than left as an on-demand export)
|
|
139
|
+
"duration_median_ms": dur["median"] if dur else None,
|
|
140
|
+
"duration_iqr_ms": f"[{dur['q1']},{dur['q3']}]" if dur else None,
|
|
141
|
+
"duration_range_ms": f"[{dur['min']},{dur['max']}]" if dur else None,
|
|
142
|
+
}
|
|
143
|
+
if calibrated:
|
|
144
|
+
row["p_emp"] = round(it.p_emp, 4)
|
|
145
|
+
row["fdr_q"] = round(it.fdr_q, 4)
|
|
146
|
+
row["fwer_keep"] = int(it.fwer_keep)
|
|
147
|
+
row["analytic_strength"] = round(it.strength, 2) # -log10(analytic p); comparison only
|
|
148
|
+
row["interpretation"] = interpret(p, it.N, it.fdr_q, ci_unit=ci_unit, dur=dur)
|
|
149
|
+
rows.append(row)
|
|
150
|
+
|
|
151
|
+
key = sort
|
|
152
|
+
if sort == "auto":
|
|
153
|
+
key = "p_emp" if calibrated else "N"
|
|
154
|
+
if key == "N":
|
|
155
|
+
rows.sort(key=lambda r: (-r["N"], r["level"]))
|
|
156
|
+
elif key in ("p_emp", "fdr_q") and calibrated:
|
|
157
|
+
rows.sort(key=lambda r: (r[key], -r["N"]))
|
|
158
|
+
|
|
159
|
+
if outfile:
|
|
160
|
+
with open(outfile, "w", newline="") as fh:
|
|
161
|
+
w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()) if rows else ["pattern"])
|
|
162
|
+
w.writeheader()
|
|
163
|
+
w.writerows(rows)
|
|
164
|
+
return rows
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --------------------------------------------------------------- forest plot
|
|
168
|
+
def forest_plot(items, outfile: str, title: str = "Effect sizes (odds ratios)",
|
|
169
|
+
xlabel: str = "odds ratio (log scale)"):
|
|
170
|
+
"""Forest plot of odds ratios with 95% CIs.
|
|
171
|
+
|
|
172
|
+
`items` is a list of dicts, each: {label, or, lo, hi, p (optional), n (opt)}.
|
|
173
|
+
A reference line is drawn at OR = 1. Points whose CI excludes 1 are coloured.
|
|
174
|
+
"""
|
|
175
|
+
items = list(items)
|
|
176
|
+
if not items:
|
|
177
|
+
return
|
|
178
|
+
n = len(items)
|
|
179
|
+
fig, ax = plt.subplots(figsize=(9, 0.6 * n + 1.4))
|
|
180
|
+
ys = list(range(n, 0, -1)) # top-to-bottom in given order
|
|
181
|
+
|
|
182
|
+
# fix x-range up front so label anchoring is stable on the log scale.
|
|
183
|
+
# An odds-ratio CI can hit 0 or inf when a group has a zero cell, and neither
|
|
184
|
+
# is a valid limit on a log axis, so fall back to sensible finite bounds.
|
|
185
|
+
los = [it["lo"] for it in items]; his = [it["hi"] for it in items]
|
|
186
|
+
pos_lo = [x for x in los if x and x > 0]
|
|
187
|
+
fin_hi = [x for x in his if x not in (float("inf"), float("nan")) and x > 0]
|
|
188
|
+
ax.set_xscale("log")
|
|
189
|
+
ax.set_xlim((min(pos_lo) if pos_lo else 0.1) * 0.7,
|
|
190
|
+
(max(fin_hi) if fin_hi else 10.0) * 1.35)
|
|
191
|
+
ax.set_ylim(0.3, n + 0.9)
|
|
192
|
+
# label in axes-x (fixed left/right), data-y
|
|
193
|
+
tx = blended_transform_factory(ax.transAxes, ax.transData)
|
|
194
|
+
|
|
195
|
+
for y, it in zip(ys, items):
|
|
196
|
+
lo, hi, orr = it["lo"], it["hi"], it["or"]
|
|
197
|
+
sig = lo > 1 or hi < 1
|
|
198
|
+
colour = "#c0392b" if sig else "#7f8c8d"
|
|
199
|
+
ax.plot([lo, hi], [y, y], color=colour, lw=1.8, solid_capstyle="round")
|
|
200
|
+
ax.plot([orr], [y], "o", color=colour, ms=7)
|
|
201
|
+
lbl = it["label"]
|
|
202
|
+
if it.get("p") is not None:
|
|
203
|
+
lbl += f" (p={it['p']:.3g})"
|
|
204
|
+
ax.text(0.005, y + 0.18, lbl, transform=tx, ha="left", va="bottom", fontsize=8)
|
|
205
|
+
ax.text(0.995, y, f"{orr:.2f} [{lo:.2f}, {hi:.2f}]", transform=tx,
|
|
206
|
+
ha="right", va="center", fontsize=7.5, color="#555")
|
|
207
|
+
|
|
208
|
+
ax.axvline(1.0, color="#2c3e50", lw=1.0, ls="--")
|
|
209
|
+
ax.set_yticks([])
|
|
210
|
+
ax.set_xlabel(xlabel)
|
|
211
|
+
ax.set_title(title, fontsize=11)
|
|
212
|
+
for s in ("top", "right", "left"):
|
|
213
|
+
ax.spines[s].set_visible(False)
|
|
214
|
+
plt.tight_layout(); plt.savefig(outfile, dpi=150); plt.close()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# -------------------------------------------------- extra tables (default outputs)
|
|
218
|
+
# THEME produces a menu of statistical export tables, buried under Tools -> Generate
|
|
219
|
+
# Tables and off by default. These reproduce the useful ones and emit them by default,
|
|
220
|
+
# because the application a user will put them to cannot be predicted and nothing that
|
|
221
|
+
# could inform a reading should be hidden.
|
|
222
|
+
|
|
223
|
+
def _leaves(p):
|
|
224
|
+
"""Ordered event-type leaves of a pattern, left to right (temporal order)."""
|
|
225
|
+
if p.is_terminal:
|
|
226
|
+
return [p.event]
|
|
227
|
+
return _leaves(p.left) + _leaves(p.right)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _patterns_of(source):
|
|
231
|
+
if isinstance(source, CalibrationResult):
|
|
232
|
+
return [c.pattern for c in source.real]
|
|
233
|
+
return [p for p in source if p.level >= 1]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _write_csv(rows, outfile):
|
|
237
|
+
if outfile and rows:
|
|
238
|
+
with open(outfile, "w", newline="") as fh:
|
|
239
|
+
w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
|
|
240
|
+
w.writeheader(); w.writerows(rows)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def pattern_durations(source, outfile: str | None = None):
|
|
244
|
+
"""One row per pattern *occurrence*: its observation, start, end and duration
|
|
245
|
+
(ms). This is THEME's "Pattern Duration" export, produced by default. The
|
|
246
|
+
per-pattern summary is in `patterns_table` (duration_median_ms etc.); this is the
|
|
247
|
+
raw occurrence-level table for a reader who wants the full distribution."""
|
|
248
|
+
rows = []
|
|
249
|
+
for p in _patterns_of(source):
|
|
250
|
+
for inst in p.instances:
|
|
251
|
+
rows.append({"pattern": str(p), "signature": p.signature(),
|
|
252
|
+
"observation": inst.obs, "start_ms": inst.start,
|
|
253
|
+
"end_ms": inst.end, "duration_ms": inst.end - inst.start})
|
|
254
|
+
rows.sort(key=lambda r: (r["signature"], r["start_ms"]))
|
|
255
|
+
_write_csv(rows, outfile)
|
|
256
|
+
return rows
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def event_type_roles(source, outfile: str | None = None):
|
|
260
|
+
"""For each event type, how often it sits first, in the middle (inner) or last
|
|
261
|
+
within the detected patterns (THEME's "Event-Types in Patterns"). Counts are
|
|
262
|
+
weighted by pattern occurrences (N). Reveals, e.g., which events start chains and
|
|
263
|
+
which end them."""
|
|
264
|
+
from collections import defaultdict
|
|
265
|
+
roles = defaultdict(lambda: {"in_patterns": 0, "as_first": 0, "as_inner": 0, "as_last": 0})
|
|
266
|
+
for p in _patterns_of(source):
|
|
267
|
+
leaves = _leaves(p)
|
|
268
|
+
for i, ev in enumerate(leaves):
|
|
269
|
+
r = roles[ev]; r["in_patterns"] += p.N
|
|
270
|
+
slot = "as_first" if i == 0 else "as_last" if i == len(leaves) - 1 else "as_inner"
|
|
271
|
+
r[slot] += p.N
|
|
272
|
+
rows = []
|
|
273
|
+
for ev, r in sorted(roles.items(), key=lambda kv: -kv[1]["in_patterns"]):
|
|
274
|
+
tot = r["in_patterns"] or 1
|
|
275
|
+
rows.append({"event_type": ev, **r,
|
|
276
|
+
"pct_first": round(r["as_first"] / tot, 3),
|
|
277
|
+
"pct_inner": round(r["as_inner"] / tot, 3),
|
|
278
|
+
"pct_last": round(r["as_last"] / tot, 3)})
|
|
279
|
+
_write_csv(rows, outfile)
|
|
280
|
+
return rows
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def connections(source, outfile: str | None = None):
|
|
284
|
+
"""For each event type, which other types follow it within at least one detected
|
|
285
|
+
pattern, and how many distinct ones (THEME's "Connections per Event-Type"). A
|
|
286
|
+
compact 'what follows what' view of the detected structure."""
|
|
287
|
+
from collections import defaultdict
|
|
288
|
+
follows = defaultdict(set)
|
|
289
|
+
appears = defaultdict(int)
|
|
290
|
+
for p in _patterns_of(source):
|
|
291
|
+
leaves = _leaves(p)
|
|
292
|
+
for i, a in enumerate(leaves):
|
|
293
|
+
appears[a] += 1
|
|
294
|
+
for b in leaves[i + 1:]:
|
|
295
|
+
follows[a].add(b)
|
|
296
|
+
rows = [{"event_type": ev, "in_patterns": appears[ev],
|
|
297
|
+
"n_followed_by": len(follows[ev]),
|
|
298
|
+
"followed_by": ", ".join(sorted(follows[ev]))}
|
|
299
|
+
for ev in sorted(follows, key=lambda e: -len(follows[e]))]
|
|
300
|
+
_write_csv(rows, outfile)
|
|
301
|
+
return rows
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def data_summary(observations, outfile: str | None = None):
|
|
305
|
+
"""Descriptive table of the input before any detection: per event type its count,
|
|
306
|
+
how many observations contain it, its mean rate per observation and share of all
|
|
307
|
+
events (THEME's event-type/overview tables). Reported so a reader can see the
|
|
308
|
+
shape of the data that produced the patterns."""
|
|
309
|
+
from collections import Counter, defaultdict
|
|
310
|
+
counts = Counter(); in_obs = defaultdict(set)
|
|
311
|
+
for o in observations:
|
|
312
|
+
for _, ev in o.events:
|
|
313
|
+
counts[ev] += 1; in_obs[ev].add(o.name)
|
|
314
|
+
n_obs = len(observations); n_ev = sum(counts.values()) or 1
|
|
315
|
+
rows = [{"event_type": ev, "count": c, "observations": len(in_obs[ev]),
|
|
316
|
+
"mean_per_observation": round(c / (n_obs or 1), 3),
|
|
317
|
+
"pct_of_events": round(c / n_ev, 4)}
|
|
318
|
+
for ev, c in counts.most_common()]
|
|
319
|
+
_write_csv(rows, outfile)
|
|
320
|
+
return rows
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def interval_counts(source, intervals, outfile: str | None = None, split_time=None):
|
|
324
|
+
"""Opt-in: count each pattern's occurrences that begin within given time windows,
|
|
325
|
+
and (optionally) before vs after a split time (THEME's "Interval Counts"). Useful
|
|
326
|
+
for phase analysis, e.g. how a pattern's rate differs across halves of a session.
|
|
327
|
+
`intervals` is a list of (t1, t2) pairs in ms."""
|
|
328
|
+
rows = []
|
|
329
|
+
for p in _patterns_of(source):
|
|
330
|
+
starts = [inst.start for inst in p.instances]
|
|
331
|
+
row = {"pattern": str(p), "signature": p.signature(), "N": p.N}
|
|
332
|
+
for t1, t2 in intervals:
|
|
333
|
+
row[f"in[{t1},{t2}]"] = sum(1 for s in starts if t1 <= s <= t2)
|
|
334
|
+
if split_time is not None:
|
|
335
|
+
row["before_split"] = sum(1 for s in starts if s < split_time)
|
|
336
|
+
row["after_split"] = sum(1 for s in starts if s >= split_time)
|
|
337
|
+
rows.append(row)
|
|
338
|
+
_write_csv(rows, outfile)
|
|
339
|
+
return rows
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ------------------------------------------------------------------- report
|
|
343
|
+
def report(source, outdir: str, *, ci_unit: str = "ms", title: str = "T-pattern report",
|
|
344
|
+
effects=None, max_dendrograms: int = 8, observations=None):
|
|
345
|
+
"""Write a full report to `outdir`: table (CSV), top-pattern dendrograms and,
|
|
346
|
+
if `effects` given, a forest plot. Returns paths written."""
|
|
347
|
+
os.makedirs(outdir, exist_ok=True)
|
|
348
|
+
calibrated = isinstance(source, CalibrationResult)
|
|
349
|
+
patterns = [c.pattern for c in source.real] if calibrated else \
|
|
350
|
+
[p for p in source if p.level >= 1]
|
|
351
|
+
|
|
352
|
+
written = {}
|
|
353
|
+
written["table"] = os.path.join(outdir, "patterns_table.csv")
|
|
354
|
+
rows = patterns_table(source, written["table"], ci_unit=ci_unit)
|
|
355
|
+
|
|
356
|
+
# extra default tables (nothing hidden): per-occurrence durations, event-type
|
|
357
|
+
# roles, connections, and — if the raw observations are passed — a data summary.
|
|
358
|
+
written["durations"] = os.path.join(outdir, "pattern_durations.csv")
|
|
359
|
+
pattern_durations(source, written["durations"])
|
|
360
|
+
written["roles"] = os.path.join(outdir, "event_type_roles.csv")
|
|
361
|
+
event_type_roles(source, written["roles"])
|
|
362
|
+
written["connections"] = os.path.join(outdir, "connections.csv")
|
|
363
|
+
connections(source, written["connections"])
|
|
364
|
+
if observations is not None:
|
|
365
|
+
written["data_summary"] = os.path.join(outdir, "data_summary.csv")
|
|
366
|
+
data_summary(observations, written["data_summary"])
|
|
367
|
+
|
|
368
|
+
if patterns:
|
|
369
|
+
written["dendrograms"] = os.path.join(outdir, "patterns_overview.png")
|
|
370
|
+
patterns_overview(patterns, written["dendrograms"],
|
|
371
|
+
max_rows=max_dendrograms, ci_unit=ci_unit)
|
|
372
|
+
# temporal-signature plot: the per-occurrence duration spread per pattern
|
|
373
|
+
from .viz import duration_plot
|
|
374
|
+
written["durations_plot"] = os.path.join(outdir, "pattern_durations.png")
|
|
375
|
+
duration_plot(source, written["durations_plot"], max_rows=max_dendrograms,
|
|
376
|
+
ci_unit=ci_unit)
|
|
377
|
+
|
|
378
|
+
if observations is not None:
|
|
379
|
+
from .viz import occurrence_plot
|
|
380
|
+
written["occurrence_plot"] = os.path.join(outdir, "event_occurrences.png")
|
|
381
|
+
occurrence_plot(observations, written["occurrence_plot"])
|
|
382
|
+
|
|
383
|
+
if effects:
|
|
384
|
+
written["forest"] = os.path.join(outdir, "effect_sizes.png")
|
|
385
|
+
forest_plot(effects, written["forest"])
|
|
386
|
+
|
|
387
|
+
# short text summary
|
|
388
|
+
written["summary"] = os.path.join(outdir, "SUMMARY.txt")
|
|
389
|
+
with open(written["summary"], "w") as fh:
|
|
390
|
+
fh.write(f"{title}\n{'=' * len(title)}\n\n")
|
|
391
|
+
fh.write(f"patterns (level >= 1): {len(patterns)}\n")
|
|
392
|
+
if calibrated:
|
|
393
|
+
kept_fdr = len(source.kept('fdr'))
|
|
394
|
+
kept_fwer = len(source.kept('fwer'))
|
|
395
|
+
fh.write(f"null: {source.null} B={source.B} "
|
|
396
|
+
f"alpha={source.alpha} q_target={source.q_target}\n")
|
|
397
|
+
fh.write(f"kept (FDR q<={source.q_target}): {kept_fdr}\n")
|
|
398
|
+
fh.write(f"kept (FWER Holm): {kept_fwer}\n")
|
|
399
|
+
fh.write("\ntop patterns:\n")
|
|
400
|
+
for r in rows[:15]:
|
|
401
|
+
line = f" N={r['N']:>3} L{r['level']} {r['pattern']}"
|
|
402
|
+
if calibrated:
|
|
403
|
+
line += f" p_emp={r['p_emp']} q={r['fdr_q']}"
|
|
404
|
+
fh.write(line + "\n")
|
|
405
|
+
return written
|
|
@@ -10,6 +10,8 @@ cramped detection tree; here it is a clean, labelled figure.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import statistics as _stat
|
|
14
|
+
|
|
13
15
|
import matplotlib
|
|
14
16
|
matplotlib.use("Agg")
|
|
15
17
|
import matplotlib.pyplot as plt
|
|
@@ -17,6 +19,41 @@ import matplotlib.pyplot as plt
|
|
|
17
19
|
from .pattern import Pattern
|
|
18
20
|
|
|
19
21
|
|
|
22
|
+
# --------------------------------------------------------------------- styling
|
|
23
|
+
# One place to change how every figure looks. Edit these values (or set them at
|
|
24
|
+
# run time, e.g. `tpattern.viz.STYLE["accent"] = "#0b7285"`) to restyle all plots
|
|
25
|
+
# without touching the plotting code — colours, fonts, grid and resolution are all
|
|
26
|
+
# read from here. Kept deliberately small and legible so it is easy to customise.
|
|
27
|
+
STYLE = {
|
|
28
|
+
"node": "#2980b9", # event-type leaf markers
|
|
29
|
+
"line": "#34495e", # dendrogram links
|
|
30
|
+
"interval": "#c0392b", # timing annotations
|
|
31
|
+
"accent": "#4a6fa5", # primary bar/point colour
|
|
32
|
+
"accent2": "#c07a3e", # secondary group colour
|
|
33
|
+
"muted": "#8a93a3", # captions, secondary text
|
|
34
|
+
"text": "#1a1c22", # primary text
|
|
35
|
+
"grid": "#dfe3e8", # gridlines
|
|
36
|
+
"font_size": 8.0,
|
|
37
|
+
"title_size": 10.5,
|
|
38
|
+
"dpi": 150,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _tidy(ax, *, grid_axis=None):
|
|
43
|
+
"""Apply the shared look to an Axes: drop the top/right frame, optional soft grid."""
|
|
44
|
+
ax.spines[["top", "right"]].set_visible(False)
|
|
45
|
+
if grid_axis:
|
|
46
|
+
ax.grid(axis=grid_axis, color=STYLE["grid"], lw=0.7, zorder=0)
|
|
47
|
+
ax.tick_params(labelsize=STYLE["font_size"] - 0.5)
|
|
48
|
+
return ax
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _median_duration(p):
|
|
52
|
+
"""Median of a (sub)pattern's occurrence durations in ms, or None."""
|
|
53
|
+
ds = [i.end - i.start for i in p.instances]
|
|
54
|
+
return round(_stat.median(ds)) if ds else None
|
|
55
|
+
|
|
56
|
+
|
|
20
57
|
def _layout(p: Pattern, leaves: list, pos: dict):
|
|
21
58
|
"""Assign (x, depth) to every node. Leaves get sequential x; internal nodes
|
|
22
59
|
sit at the mid-x of their children, depth = 1 + max child depth."""
|
|
@@ -41,14 +78,20 @@ def _draw(p: Pattern, pos: dict, ax, ci_unit: str):
|
|
|
41
78
|
for child in (p.left, p.right):
|
|
42
79
|
cx, cd = pos[id(child)]
|
|
43
80
|
# elbow: up from child to this node's depth, then across
|
|
44
|
-
ax.plot([cx, cx], [cd, d], color="
|
|
45
|
-
ax.plot([cx, x], [d, d], color="
|
|
81
|
+
ax.plot([cx, cx], [cd, d], color=STYLE["line"], lw=1.4)
|
|
82
|
+
ax.plot([cx, x], [d, d], color=STYLE["line"], lw=1.4)
|
|
46
83
|
_draw(child, pos, ax, ci_unit)
|
|
47
|
-
# annotate the
|
|
84
|
+
# annotate the join with the OBSERVED median timing (the interpretable value) and,
|
|
85
|
+
# smaller, the critical interval it was accepted within.
|
|
86
|
+
med = _median_duration(p)
|
|
87
|
+
if med is not None:
|
|
88
|
+
ax.annotate(f"~{med} {ci_unit}".strip(), (x, d), textcoords="offset points",
|
|
89
|
+
xytext=(0, 5), ha="center", fontsize=7, fontweight="bold",
|
|
90
|
+
color=STYLE["interval"])
|
|
48
91
|
if p.ci is not None:
|
|
49
92
|
d1, d2 = p.ci
|
|
50
|
-
ax.annotate(f"[{d1},{d2}]
|
|
51
|
-
xytext=(0,
|
|
93
|
+
ax.annotate(f"[{d1},{d2}]", (x, d), textcoords="offset points",
|
|
94
|
+
xytext=(0, -9), ha="center", fontsize=6, color=STYLE["muted"])
|
|
52
95
|
|
|
53
96
|
|
|
54
97
|
def pattern_dendrogram(pattern: Pattern, title: str | None = None,
|
|
@@ -70,7 +113,7 @@ def pattern_dendrogram(pattern: Pattern, title: str | None = None,
|
|
|
70
113
|
|
|
71
114
|
# leaf labels
|
|
72
115
|
for i, name in enumerate(leaves):
|
|
73
|
-
ax.plot([i], [0], "o", color="
|
|
116
|
+
ax.plot([i], [0], "o", color=STYLE["node"], ms=6)
|
|
74
117
|
ax.annotate(name, (i, 0), textcoords="offset points", xytext=(0, -8),
|
|
75
118
|
ha="right", va="top", rotation=35, fontsize=8)
|
|
76
119
|
|
|
@@ -274,3 +317,80 @@ def null_comparison_plot(comparison, outfile, title=None, max_rows=25):
|
|
|
274
317
|
fig.savefig(outfile)
|
|
275
318
|
plt.close(fig)
|
|
276
319
|
return ordered
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def occurrence_plot(observations, outfile: str, title: str | None = None,
|
|
323
|
+
max_types: int = 25):
|
|
324
|
+
"""When does each event type happen? A strip plot of every occurrence's position
|
|
325
|
+
within its observation (0 = start of the window, 1 = end), one row per event type,
|
|
326
|
+
with the median marked. This is THEME's event-type occurrence view, and it shows
|
|
327
|
+
directly whether a type clusters early or late — the same structure the
|
|
328
|
+
conditional-uniformity diagnostic tests. Returns the event types plotted."""
|
|
329
|
+
from collections import defaultdict
|
|
330
|
+
pos = defaultdict(list)
|
|
331
|
+
for o in observations:
|
|
332
|
+
T = (o.end - o.start) or 1
|
|
333
|
+
for t, ev in o.events:
|
|
334
|
+
pos[ev].append((t - o.start) / T)
|
|
335
|
+
types = sorted(pos, key=lambda e: _stat.median(pos[e]))
|
|
336
|
+
types = types[:max_types]
|
|
337
|
+
n = len(types)
|
|
338
|
+
fig, ax = plt.subplots(figsize=(7.5, max(2.0, 0.32 * n + 1.0)), dpi=STYLE["dpi"])
|
|
339
|
+
for y, ev in enumerate(types):
|
|
340
|
+
xs = pos[ev]
|
|
341
|
+
ax.scatter(xs, [y] * len(xs), s=10, color=STYLE["accent"], alpha=0.35,
|
|
342
|
+
edgecolors="none", zorder=2)
|
|
343
|
+
ax.plot([_stat.median(xs)], [y], "|", color=STYLE["interval"], ms=16,
|
|
344
|
+
mew=2.2, zorder=3)
|
|
345
|
+
ax.set_yticks(range(n)); ax.set_yticklabels(types, fontsize=STYLE["font_size"] - 1)
|
|
346
|
+
ax.set_xlim(-0.02, 1.02); ax.set_xlabel("position within observation (0 = start, 1 = end)",
|
|
347
|
+
fontsize=STYLE["font_size"])
|
|
348
|
+
ax.set_ylim(-0.6, n - 0.4)
|
|
349
|
+
_tidy(ax, grid_axis="x")
|
|
350
|
+
ax.set_title(title or "When each event type occurs", fontsize=STYLE["title_size"],
|
|
351
|
+
fontweight="bold")
|
|
352
|
+
fig.text(0.99, 0.005, "red mark = median position", ha="right",
|
|
353
|
+
fontsize=STYLE["font_size"] - 1.5, color=STYLE["muted"])
|
|
354
|
+
fig.tight_layout(); fig.savefig(outfile); plt.close(fig)
|
|
355
|
+
return types
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def duration_plot(source, outfile: str, title: str | None = None, max_rows: int = 8,
|
|
359
|
+
ci_unit: str = "ms"):
|
|
360
|
+
"""Temporal signature of each pattern: a strip of its occurrence durations (ms),
|
|
361
|
+
one row per pattern, median marked. Turns 'N=14, q=.006' into a picture of *when*
|
|
362
|
+
the later event lands relative to the earlier. Returns the patterns plotted."""
|
|
363
|
+
from .significance import CalibrationResult
|
|
364
|
+
if isinstance(source, CalibrationResult):
|
|
365
|
+
pats = [c.pattern for c in source.real]
|
|
366
|
+
else:
|
|
367
|
+
pats = [p for p in source if p.level >= 1]
|
|
368
|
+
pats = sorted(pats, key=lambda p: -p.N)[:max_rows]
|
|
369
|
+
pats = [p for p in pats if p.instances]
|
|
370
|
+
n = len(pats)
|
|
371
|
+
if not n:
|
|
372
|
+
fig, ax = plt.subplots(figsize=(6, 2), dpi=STYLE["dpi"])
|
|
373
|
+
ax.text(0.5, 0.5, "No patterns to plot.", ha="center", va="center")
|
|
374
|
+
ax.axis("off"); fig.savefig(outfile); plt.close(fig); return []
|
|
375
|
+
fig, ax = plt.subplots(figsize=(7.5, max(2.0, 0.5 * n + 1.0)), dpi=STYLE["dpi"])
|
|
376
|
+
labels = []
|
|
377
|
+
for y, p in enumerate(pats):
|
|
378
|
+
ds = sorted(i.end - i.start for i in p.instances)
|
|
379
|
+
ax.scatter(ds, [y] * len(ds), s=22, color=STYLE["accent"], alpha=0.5,
|
|
380
|
+
edgecolors="none", zorder=2)
|
|
381
|
+
med = _stat.median(ds)
|
|
382
|
+
ax.plot([med], [y], "|", color=STYLE["interval"], ms=18, mew=2.4, zorder=3)
|
|
383
|
+
ax.annotate(f"~{round(med)} {ci_unit}", (med, y), textcoords="offset points",
|
|
384
|
+
xytext=(0, 7), ha="center", fontsize=STYLE["font_size"] - 1.5,
|
|
385
|
+
color=STYLE["interval"])
|
|
386
|
+
sig = p.signature()
|
|
387
|
+
labels.append(sig if len(sig) <= 40 else sig[:37] + "...")
|
|
388
|
+
ax.set_yticks(range(n)); ax.set_yticklabels(labels, fontsize=STYLE["font_size"] - 1)
|
|
389
|
+
ax.set_ylim(-0.6, n - 0.2)
|
|
390
|
+
ax.set_xlabel(f"pattern occurrence duration ({ci_unit})", fontsize=STYLE["font_size"])
|
|
391
|
+
ax.set_xlim(left=0)
|
|
392
|
+
_tidy(ax, grid_axis="x")
|
|
393
|
+
ax.set_title(title or "Temporal signature (duration of each pattern's occurrences)",
|
|
394
|
+
fontsize=STYLE["title_size"], fontweight="bold")
|
|
395
|
+
fig.tight_layout(); fig.savefig(outfile); plt.close(fig)
|
|
396
|
+
return pats
|
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Reporting — the output layer THEME does poorly.
|
|
3
|
-
|
|
4
|
-
Three things:
|
|
5
|
-
|
|
6
|
-
1. `patterns_table` — a tidy results table for detected patterns: the pattern
|
|
7
|
-
string, N, length, level, loop flag, critical interval, and (when a
|
|
8
|
-
calibration is supplied) the surrogate empirical p with **repeated-testing
|
|
9
|
-
correction** (Benjamini–Hochberg q for screening, Holm/FWER keep for
|
|
10
|
-
confirmation). Writes CSV and returns the rows.
|
|
11
|
-
|
|
12
|
-
2. `forest_plot` — effect sizes with confidence intervals (odds ratios from the
|
|
13
|
-
group/outcome contrasts) as a forest plot, the standard way to show
|
|
14
|
-
which contrasts matter and how uncertain they are.
|
|
15
|
-
|
|
16
|
-
3. `report` — one call that writes the table, the top-pattern dendrograms and (if
|
|
17
|
-
given) the forest plot into an output folder, so a detection run yields a
|
|
18
|
-
ready-to-read report.
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
from __future__ import annotations
|
|
22
|
-
|
|
23
|
-
import csv
|
|
24
|
-
import math
|
|
25
|
-
import os
|
|
26
|
-
|
|
27
|
-
import matplotlib
|
|
28
|
-
matplotlib.use("Agg")
|
|
29
|
-
import matplotlib.pyplot as plt
|
|
30
|
-
from matplotlib.transforms import blended_transform_factory
|
|
31
|
-
|
|
32
|
-
from .pattern import Pattern
|
|
33
|
-
from .significance import CalibrationResult, Calibrated
|
|
34
|
-
from .viz import patterns_overview
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
# --------------------------------------------------------------------- table
|
|
38
|
-
def _fmt_ci(ci, unit):
|
|
39
|
-
if not ci:
|
|
40
|
-
return ""
|
|
41
|
-
return f"[{ci[0]},{ci[1]}]{unit}"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def interpret(pattern, N, fdr_q, *, q_target=0.05, ci_unit=""):
|
|
45
|
-
"""A plain-English reading of one calibrated pattern — what it is, whether it
|
|
46
|
-
is trustworthy, and its timing. Used as the results table's `interpretation`
|
|
47
|
-
column so a non-specialist can read the output directly."""
|
|
48
|
-
if fdr_q <= q_target:
|
|
49
|
-
verdict, meaning = (f"Robust (survives FDR, q={fdr_q:.3g})",
|
|
50
|
-
"a genuine recurring sequence")
|
|
51
|
-
elif fdr_q <= 2 * q_target:
|
|
52
|
-
verdict, meaning = (f"Borderline (q={fdr_q:.3g})",
|
|
53
|
-
"near the significance threshold — treat with caution and "
|
|
54
|
-
"re-check with more surrogates")
|
|
55
|
-
else:
|
|
56
|
-
verdict, meaning = (f"Not significant after correction (q={fdr_q:.3g})",
|
|
57
|
-
"consistent with chance")
|
|
58
|
-
timing = ""
|
|
59
|
-
if pattern.ci and not pattern.is_terminal:
|
|
60
|
-
d1, d2 = pattern.ci
|
|
61
|
-
timing = (f"; its components are linked within {d1}–{d2}{ci_unit}"
|
|
62
|
-
if d1 != d2 else f"; components co-occur (0{ci_unit} apart)")
|
|
63
|
-
loop = " (a recycling loop: a repeated event type)" if pattern.has_loop else ""
|
|
64
|
-
plural = "occurrence" if N == 1 else "occurrences"
|
|
65
|
-
return f"{verdict}: {meaning}, {N} {plural}{timing}{loop}."
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def patterns_table(source, outfile: str | None = None, ci_unit: str = "",
|
|
69
|
-
sort: str = "auto", n_observations: int = 0):
|
|
70
|
-
"""Build a results table from a list of `Pattern` or a `CalibrationResult`.
|
|
71
|
-
|
|
72
|
-
Returns a list of row dicts. If `outfile` is given, also writes CSV.
|
|
73
|
-
When given a CalibrationResult, includes p_emp, fdr_q, fwer_keep and sorts by
|
|
74
|
-
p_emp; otherwise sorts by N (descending).
|
|
75
|
-
|
|
76
|
-
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.
|
|
81
|
-
"""
|
|
82
|
-
calibrated = isinstance(source, CalibrationResult)
|
|
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)
|
|
85
|
-
|
|
86
|
-
rows = []
|
|
87
|
-
for it in items:
|
|
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})
|
|
94
|
-
row = {
|
|
95
|
-
"pattern": str(p),
|
|
96
|
-
"signature": p.signature(),
|
|
97
|
-
"N": p.N,
|
|
98
|
-
"bouts": bouts,
|
|
99
|
-
"bout_support": round(bouts / n_obs, 4) if n_obs else None,
|
|
100
|
-
"length": p.length,
|
|
101
|
-
"level": p.level,
|
|
102
|
-
"loop": int(p.has_loop),
|
|
103
|
-
"critical_interval": _fmt_ci(p.ci, ci_unit),
|
|
104
|
-
}
|
|
105
|
-
if calibrated:
|
|
106
|
-
row["p_emp"] = round(it.p_emp, 4)
|
|
107
|
-
row["fdr_q"] = round(it.fdr_q, 4)
|
|
108
|
-
row["fwer_keep"] = int(it.fwer_keep)
|
|
109
|
-
row["analytic_strength"] = round(it.strength, 2) # -log10(analytic p); comparison only
|
|
110
|
-
row["interpretation"] = interpret(p, it.N, it.fdr_q, ci_unit=ci_unit)
|
|
111
|
-
rows.append(row)
|
|
112
|
-
|
|
113
|
-
key = sort
|
|
114
|
-
if sort == "auto":
|
|
115
|
-
key = "p_emp" if calibrated else "N"
|
|
116
|
-
if key == "N":
|
|
117
|
-
rows.sort(key=lambda r: (-r["N"], r["level"]))
|
|
118
|
-
elif key in ("p_emp", "fdr_q") and calibrated:
|
|
119
|
-
rows.sort(key=lambda r: (r[key], -r["N"]))
|
|
120
|
-
|
|
121
|
-
if outfile:
|
|
122
|
-
with open(outfile, "w", newline="") as fh:
|
|
123
|
-
w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()) if rows else ["pattern"])
|
|
124
|
-
w.writeheader()
|
|
125
|
-
w.writerows(rows)
|
|
126
|
-
return rows
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
# --------------------------------------------------------------- forest plot
|
|
130
|
-
def forest_plot(items, outfile: str, title: str = "Effect sizes (odds ratios)",
|
|
131
|
-
xlabel: str = "odds ratio (log scale)"):
|
|
132
|
-
"""Forest plot of odds ratios with 95% CIs.
|
|
133
|
-
|
|
134
|
-
`items` is a list of dicts, each: {label, or, lo, hi, p (optional), n (opt)}.
|
|
135
|
-
A reference line is drawn at OR = 1. Points whose CI excludes 1 are coloured.
|
|
136
|
-
"""
|
|
137
|
-
items = list(items)
|
|
138
|
-
if not items:
|
|
139
|
-
return
|
|
140
|
-
n = len(items)
|
|
141
|
-
fig, ax = plt.subplots(figsize=(9, 0.6 * n + 1.4))
|
|
142
|
-
ys = list(range(n, 0, -1)) # top-to-bottom in given order
|
|
143
|
-
|
|
144
|
-
# fix x-range up front so label anchoring is stable on the log scale.
|
|
145
|
-
# An odds-ratio CI can hit 0 or inf when a group has a zero cell, and neither
|
|
146
|
-
# is a valid limit on a log axis, so fall back to sensible finite bounds.
|
|
147
|
-
los = [it["lo"] for it in items]; his = [it["hi"] for it in items]
|
|
148
|
-
pos_lo = [x for x in los if x and x > 0]
|
|
149
|
-
fin_hi = [x for x in his if x not in (float("inf"), float("nan")) and x > 0]
|
|
150
|
-
ax.set_xscale("log")
|
|
151
|
-
ax.set_xlim((min(pos_lo) if pos_lo else 0.1) * 0.7,
|
|
152
|
-
(max(fin_hi) if fin_hi else 10.0) * 1.35)
|
|
153
|
-
ax.set_ylim(0.3, n + 0.9)
|
|
154
|
-
# label in axes-x (fixed left/right), data-y
|
|
155
|
-
tx = blended_transform_factory(ax.transAxes, ax.transData)
|
|
156
|
-
|
|
157
|
-
for y, it in zip(ys, items):
|
|
158
|
-
lo, hi, orr = it["lo"], it["hi"], it["or"]
|
|
159
|
-
sig = lo > 1 or hi < 1
|
|
160
|
-
colour = "#c0392b" if sig else "#7f8c8d"
|
|
161
|
-
ax.plot([lo, hi], [y, y], color=colour, lw=1.8, solid_capstyle="round")
|
|
162
|
-
ax.plot([orr], [y], "o", color=colour, ms=7)
|
|
163
|
-
lbl = it["label"]
|
|
164
|
-
if it.get("p") is not None:
|
|
165
|
-
lbl += f" (p={it['p']:.3g})"
|
|
166
|
-
ax.text(0.005, y + 0.18, lbl, transform=tx, ha="left", va="bottom", fontsize=8)
|
|
167
|
-
ax.text(0.995, y, f"{orr:.2f} [{lo:.2f}, {hi:.2f}]", transform=tx,
|
|
168
|
-
ha="right", va="center", fontsize=7.5, color="#555")
|
|
169
|
-
|
|
170
|
-
ax.axvline(1.0, color="#2c3e50", lw=1.0, ls="--")
|
|
171
|
-
ax.set_yticks([])
|
|
172
|
-
ax.set_xlabel(xlabel)
|
|
173
|
-
ax.set_title(title, fontsize=11)
|
|
174
|
-
for s in ("top", "right", "left"):
|
|
175
|
-
ax.spines[s].set_visible(False)
|
|
176
|
-
plt.tight_layout(); plt.savefig(outfile, dpi=150); plt.close()
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
# ------------------------------------------------------------------- report
|
|
180
|
-
def report(source, outdir: str, *, ci_unit: str = "", title: str = "T-pattern report",
|
|
181
|
-
effects=None, max_dendrograms: int = 8):
|
|
182
|
-
"""Write a full report to `outdir`: table (CSV), top-pattern dendrograms and,
|
|
183
|
-
if `effects` given, a forest plot. Returns paths written."""
|
|
184
|
-
os.makedirs(outdir, exist_ok=True)
|
|
185
|
-
calibrated = isinstance(source, CalibrationResult)
|
|
186
|
-
patterns = [c.pattern for c in source.real] if calibrated else \
|
|
187
|
-
[p for p in source if p.level >= 1]
|
|
188
|
-
|
|
189
|
-
written = {}
|
|
190
|
-
written["table"] = os.path.join(outdir, "patterns_table.csv")
|
|
191
|
-
rows = patterns_table(source, written["table"], ci_unit=ci_unit)
|
|
192
|
-
|
|
193
|
-
if patterns:
|
|
194
|
-
written["dendrograms"] = os.path.join(outdir, "patterns_overview.png")
|
|
195
|
-
patterns_overview(patterns, written["dendrograms"],
|
|
196
|
-
max_rows=max_dendrograms, ci_unit=ci_unit)
|
|
197
|
-
|
|
198
|
-
if effects:
|
|
199
|
-
written["forest"] = os.path.join(outdir, "effect_sizes.png")
|
|
200
|
-
forest_plot(effects, written["forest"])
|
|
201
|
-
|
|
202
|
-
# short text summary
|
|
203
|
-
written["summary"] = os.path.join(outdir, "SUMMARY.txt")
|
|
204
|
-
with open(written["summary"], "w") as fh:
|
|
205
|
-
fh.write(f"{title}\n{'=' * len(title)}\n\n")
|
|
206
|
-
fh.write(f"patterns (level >= 1): {len(patterns)}\n")
|
|
207
|
-
if calibrated:
|
|
208
|
-
kept_fdr = len(source.kept('fdr'))
|
|
209
|
-
kept_fwer = len(source.kept('fwer'))
|
|
210
|
-
fh.write(f"null: {source.null} B={source.B} "
|
|
211
|
-
f"alpha={source.alpha} q_target={source.q_target}\n")
|
|
212
|
-
fh.write(f"kept (FDR q<={source.q_target}): {kept_fdr}\n")
|
|
213
|
-
fh.write(f"kept (FWER Holm): {kept_fwer}\n")
|
|
214
|
-
fh.write("\ntop patterns:\n")
|
|
215
|
-
for r in rows[:15]:
|
|
216
|
-
line = f" N={r['N']:>3} L{r['level']} {r['pattern']}"
|
|
217
|
-
if calibrated:
|
|
218
|
-
line += f" p_emp={r['p_emp']} q={r['fdr_q']}"
|
|
219
|
-
fh.write(line + "\n")
|
|
220
|
-
return written
|
|
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
|
|
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
|