tpattern 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tpattern/__init__.py +36 -0
- tpattern/advisor.py +195 -0
- tpattern/ci.py +300 -0
- tpattern/contrast.py +90 -0
- tpattern/detect.py +334 -0
- tpattern/diagnostics.py +120 -0
- tpattern/guided.py +372 -0
- tpattern/io.py +214 -0
- tpattern/methods.py +132 -0
- tpattern/pattern.py +129 -0
- tpattern/randomise.py +206 -0
- tpattern/report.py +201 -0
- tpattern/significance.py +218 -0
- tpattern/synthetic.py +95 -0
- tpattern/viz.py +107 -0
- tpattern-0.1.0.dist-info/METADATA +211 -0
- tpattern-0.1.0.dist-info/RECORD +20 -0
- tpattern-0.1.0.dist-info/WHEEL +5 -0
- tpattern-0.1.0.dist-info/licenses/LICENSE +21 -0
- tpattern-0.1.0.dist-info/top_level.txt +1 -0
tpattern/guided.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A guided, transparent analysis workflow.
|
|
3
|
+
========================================
|
|
4
|
+
|
|
5
|
+
Wraps the library's pieces — read the data, inspect it, take the tool's
|
|
6
|
+
recommendation, run the calibrated analysis, and write the outputs — into a
|
|
7
|
+
step-by-step flow a non-programmer can follow. Every choice is shown and
|
|
8
|
+
overridable, so the analysis stays transparent and repeatable.
|
|
9
|
+
|
|
10
|
+
Two entry points:
|
|
11
|
+
|
|
12
|
+
run_analysis(...) pure function: load -> advise -> calibrate -> report ->
|
|
13
|
+
Methods text. No UI; used by scripts, tests and the widget.
|
|
14
|
+
launch() an interactive wizard (file upload, radio buttons, run,
|
|
15
|
+
download) for Google Colab / Jupyter. Needs ipywidgets.
|
|
16
|
+
|
|
17
|
+
The logic lives in run_analysis so it can be tested headlessly; launch() is a
|
|
18
|
+
thin UI over it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import csv
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .io import read_table
|
|
28
|
+
from .detect import Config
|
|
29
|
+
from .significance import calibrate
|
|
30
|
+
from .report import report, patterns_table
|
|
31
|
+
from .methods import methods_text
|
|
32
|
+
from .advisor import recommend
|
|
33
|
+
|
|
34
|
+
#: the most recent CalibrationResult (set by launch()); use it to redraw figures or
|
|
35
|
+
#: rebuild tables, e.g. patterns_overview([c.pattern for c in tpattern.guided.last.kept("fdr")], ...)
|
|
36
|
+
last = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class GuidedResult:
|
|
41
|
+
outdir: str
|
|
42
|
+
files: dict = field(default_factory=dict) # label -> path
|
|
43
|
+
n_detected: int = 0
|
|
44
|
+
n_survivors: int = 0
|
|
45
|
+
methods: str = ""
|
|
46
|
+
recommendation: str = ""
|
|
47
|
+
survivors: list = field(default_factory=list) # [{pattern, N, q}] surviving FDR
|
|
48
|
+
calibration: object = None # the CalibrationResult (for re-plotting)
|
|
49
|
+
ci_unit: str = ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_analysis(path, *, observation="observation", event="event", start="start",
|
|
53
|
+
obs_start=None, obs_end=None, time_unit="s",
|
|
54
|
+
null="profile", min_lag=1, B=200, q_target=0.05, alpha=0.005,
|
|
55
|
+
seed=20260714, outdir="tpattern_output", title="T-pattern analysis",
|
|
56
|
+
build_event_from=None):
|
|
57
|
+
"""Run the whole pipeline and write the outputs. Returns a GuidedResult.
|
|
58
|
+
|
|
59
|
+
This is the engine behind the wizard: it reads the canonical table, records
|
|
60
|
+
what the tool would recommend (for transparency), calibrates with the chosen
|
|
61
|
+
settings, writes the report (table, dendrograms, summary) plus an Excel copy
|
|
62
|
+
of the results table, and generates the paste-ready Methods paragraph.
|
|
63
|
+
"""
|
|
64
|
+
obs = read_table(path, observation=observation, event=event, start=start,
|
|
65
|
+
obs_start=obs_start, obs_end=obs_end, time_unit=time_unit,
|
|
66
|
+
build_event_from=build_event_from)
|
|
67
|
+
if not obs:
|
|
68
|
+
raise ValueError("No observations were read — check the column names and "
|
|
69
|
+
"that the file has one row per event (see SCHEMA.md).")
|
|
70
|
+
|
|
71
|
+
rec = str(recommend(obs))
|
|
72
|
+
cfg = Config(min_lag=min_lag)
|
|
73
|
+
result = calibrate(obs, cfg, null=null, B=B, alpha=alpha, q_target=q_target,
|
|
74
|
+
seed=seed)
|
|
75
|
+
|
|
76
|
+
Path(outdir).mkdir(parents=True, exist_ok=True)
|
|
77
|
+
written = report(result, outdir, title=title,
|
|
78
|
+
ci_unit=" ms" if time_unit == "ms" else " s")
|
|
79
|
+
|
|
80
|
+
# an Excel-friendly copy of the surviving patterns (falls back to CSV-only)
|
|
81
|
+
xlsx = _write_excel(result, q_target, Path(outdir) / "results.xlsx")
|
|
82
|
+
if xlsx:
|
|
83
|
+
written["excel"] = xlsx
|
|
84
|
+
|
|
85
|
+
methods = methods_text(cfg, observations=obs, calibration=result)
|
|
86
|
+
Path(outdir, "METHODS.txt").write_text(methods)
|
|
87
|
+
written["methods"] = str(Path(outdir, "METHODS.txt"))
|
|
88
|
+
|
|
89
|
+
survivors = [{"pattern": str(c.pattern), "N": c.N, "q": round(c.fdr_q, 4)}
|
|
90
|
+
for c in sorted(result.kept("fdr"), key=lambda c: c.p_emp)]
|
|
91
|
+
|
|
92
|
+
return GuidedResult(
|
|
93
|
+
outdir=outdir, files=written,
|
|
94
|
+
n_detected=len(result.real), n_survivors=len(result.kept("fdr")),
|
|
95
|
+
methods=methods, recommendation=rec, survivors=survivors,
|
|
96
|
+
calibration=result, ci_unit=" ms" if time_unit == "ms" else " s")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _write_excel(result, q_target, path):
|
|
100
|
+
"""Write the results table to .xlsx if pandas+openpyxl are available; else
|
|
101
|
+
a plain CSV next to it. Returns the path written, or None."""
|
|
102
|
+
rows = []
|
|
103
|
+
for c in sorted(result.real, key=lambda c: c.p_emp):
|
|
104
|
+
rows.append({
|
|
105
|
+
"pattern": str(c.pattern), "N": c.N, "level": c.level,
|
|
106
|
+
"critical_interval": f"[{c.ci[0]},{c.ci[1]}]" if c.ci else "",
|
|
107
|
+
"p_empirical": round(c.p_emp, 4), "q_FDR": round(c.fdr_q, 4),
|
|
108
|
+
"survives_FDR": int(c.fdr_q <= q_target), "survives_FWER": int(c.fwer_keep),
|
|
109
|
+
})
|
|
110
|
+
try:
|
|
111
|
+
import pandas as pd
|
|
112
|
+
pd.DataFrame(rows).to_excel(path, index=False)
|
|
113
|
+
return str(path)
|
|
114
|
+
except Exception:
|
|
115
|
+
csv_path = path.with_suffix(".csv")
|
|
116
|
+
if rows:
|
|
117
|
+
with open(csv_path, "w", newline="") as fh:
|
|
118
|
+
w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
|
|
119
|
+
w.writeheader(); w.writerows(rows)
|
|
120
|
+
return str(csv_path)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------- the wizard
|
|
125
|
+
def launch():
|
|
126
|
+
"""Interactive wizard for Google Colab / Jupyter.
|
|
127
|
+
|
|
128
|
+
Guides the user through: upload a CSV -> map the columns -> see the tool's
|
|
129
|
+
inspection and recommendation -> confirm or change the settings (radio
|
|
130
|
+
buttons, pre-set to the recommendation) -> run -> download the outputs and
|
|
131
|
+
copy the Methods sentence. Requires ipywidgets (pip install 'tpattern[gui]').
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
import ipywidgets as W
|
|
135
|
+
from IPython.display import display, clear_output
|
|
136
|
+
except ImportError as e: # pragma: no cover - UI only
|
|
137
|
+
raise ImportError(
|
|
138
|
+
"launch() needs ipywidgets — install with `pip install ipywidgets` "
|
|
139
|
+
"(or `pip install 'tpattern[gui]'`) and run in Colab/Jupyter. For a "
|
|
140
|
+
"non-interactive run use run_analysis(...).") from e
|
|
141
|
+
|
|
142
|
+
import csv as _csv
|
|
143
|
+
from collections import Counter
|
|
144
|
+
state = {"path": None}
|
|
145
|
+
HELP = "color:#5b6270;font-size:90%"
|
|
146
|
+
STYLE = {"description_width": "initial"}
|
|
147
|
+
WIDE = W.Layout(width="480px")
|
|
148
|
+
|
|
149
|
+
# ---------- Step 1: file + columns ----------
|
|
150
|
+
upload = W.FileUpload(accept=".csv", multiple=False, description="1. Upload CSV")
|
|
151
|
+
cols_info = W.HTML("")
|
|
152
|
+
obs_c = W.Text(value="observation", description="observation", style=STYLE)
|
|
153
|
+
ev_c = W.Text(value="event", description="event", style=STYLE)
|
|
154
|
+
st_c = W.Text(value="start", description="start", style=STYLE)
|
|
155
|
+
unit = W.RadioButtons(options=["s", "ms"], value="s", description="time unit",
|
|
156
|
+
style=STYLE)
|
|
157
|
+
unit_help = W.HTML(f"<span style='{HELP}'>seconds by default; choose <b>ms</b> if "
|
|
158
|
+
"your timestamps are whole numbers of milliseconds.</span>")
|
|
159
|
+
advise_btn = W.Button(description="2. Inspect & advise", button_style="info",
|
|
160
|
+
layout=W.Layout(width="200px"))
|
|
161
|
+
advice_out = W.Output()
|
|
162
|
+
|
|
163
|
+
# ---------- Step 3: method settings (revealed after inspect) ----------
|
|
164
|
+
null_w = W.RadioButtons(options=["profile", "rotation"], value="profile",
|
|
165
|
+
description="null", style=STYLE)
|
|
166
|
+
null_help = W.HTML(f"<span style='{HELP}'><b>profile</b> (recommended): checks a "
|
|
167
|
+
"pattern is a real link between actions, not just their usual "
|
|
168
|
+
"timing. <b>rotation</b>: the simpler THEME-style shuffle.</span>")
|
|
169
|
+
lag_w = W.RadioButtons(
|
|
170
|
+
options=[("require a genuine lag (min_lag = 1)", 1),
|
|
171
|
+
("allow co-timing (min_lag = 0)", 0)],
|
|
172
|
+
value=1, description="lag", style=STYLE, layout=WIDE)
|
|
173
|
+
lag_help = W.HTML(f"<span style='{HELP}'><b>genuine lag</b> (recommended for "
|
|
174
|
+
"video/frame data): events at the same timestamp are treated as "
|
|
175
|
+
"happening together, not as a sequence. <b>co-timing</b>: keeps "
|
|
176
|
+
"them as ordered (THEME's default).</span>")
|
|
177
|
+
B_w = W.IntSlider(value=200, min=100, max=2000, step=100, description="surrogates B",
|
|
178
|
+
style=STYLE)
|
|
179
|
+
B_help = W.HTML(f"<span style='{HELP}'>more surrogates = finer p-values; "
|
|
180
|
+
"family-wise (FWER) claims need B in the thousands.</span>")
|
|
181
|
+
run_btn = W.Button(description="3. Run analysis", button_style="success",
|
|
182
|
+
layout=W.Layout(width="200px"))
|
|
183
|
+
settings_box = W.VBox(
|
|
184
|
+
[W.HTML("<hr><b>Recommended method settings</b> — pre-set from your data; "
|
|
185
|
+
"each shows why. Override if you wish, then Run."),
|
|
186
|
+
null_w, null_help, lag_w, lag_help, B_w, B_help, run_btn],
|
|
187
|
+
layout=W.Layout(display="none")) # hidden until Inspect & advise
|
|
188
|
+
results_out = W.Output()
|
|
189
|
+
|
|
190
|
+
def _save_upload():
|
|
191
|
+
if not upload.value:
|
|
192
|
+
return None
|
|
193
|
+
item = list(upload.value.values())[0] if isinstance(upload.value, dict) \
|
|
194
|
+
else upload.value[0]
|
|
195
|
+
content = item["content"]
|
|
196
|
+
p = Path("uploaded_events.csv")
|
|
197
|
+
p.write_bytes(content if isinstance(content, (bytes, bytearray))
|
|
198
|
+
else bytes(content))
|
|
199
|
+
return str(p)
|
|
200
|
+
|
|
201
|
+
def _on_upload(_):
|
|
202
|
+
path = _save_upload()
|
|
203
|
+
if not path:
|
|
204
|
+
return
|
|
205
|
+
state["path"] = path
|
|
206
|
+
with open(path, newline="") as fh:
|
|
207
|
+
reader = _csv.reader(fh)
|
|
208
|
+
header = next(reader, [])
|
|
209
|
+
row2 = next(reader, [])
|
|
210
|
+
info = ("<b>Columns in your file:</b> "
|
|
211
|
+
+ ", ".join(f"<code>{c}</code>" for c in header)
|
|
212
|
+
+ f"<br><span style='{HELP}'>Set the three boxes below to match these "
|
|
213
|
+
"(defaults: observation / event / start).</span>")
|
|
214
|
+
try:
|
|
215
|
+
si = header.index(st_c.value) if st_c.value in header else 2
|
|
216
|
+
v = float(row2[si])
|
|
217
|
+
if v.is_integer() and abs(v) >= 1000:
|
|
218
|
+
info += ("<br><b style='color:#b5342a'>Tip:</b> your start times look "
|
|
219
|
+
"like <b>milliseconds</b> — select <b>ms</b> above.")
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
cols_info.value = info
|
|
223
|
+
|
|
224
|
+
upload.observe(_on_upload, names="value")
|
|
225
|
+
|
|
226
|
+
def _data_quality(obs):
|
|
227
|
+
"""(exact-duplicate rows, fraction of consecutive events that are co-timed)."""
|
|
228
|
+
dup = same = total = 0
|
|
229
|
+
for o in obs:
|
|
230
|
+
counts = Counter((t, e) for t, e in o.events)
|
|
231
|
+
dup += sum(c - 1 for c in counts.values() if c > 1)
|
|
232
|
+
ts = [t for t, _ in o.events]
|
|
233
|
+
for i in range(1, len(ts)):
|
|
234
|
+
total += 1
|
|
235
|
+
same += (ts[i] == ts[i - 1])
|
|
236
|
+
return dup, (same / total if total else 0.0)
|
|
237
|
+
|
|
238
|
+
def _on_advise(_):
|
|
239
|
+
with advice_out:
|
|
240
|
+
clear_output()
|
|
241
|
+
if not state.get("path"):
|
|
242
|
+
state["path"] = _save_upload()
|
|
243
|
+
if not state.get("path"):
|
|
244
|
+
print("Upload a CSV first."); return
|
|
245
|
+
obs = read_table(state["path"], observation=obs_c.value, event=ev_c.value,
|
|
246
|
+
start=st_c.value, time_unit=unit.value)
|
|
247
|
+
rec = recommend(obs)
|
|
248
|
+
for c in rec.choices: # pre-set the widgets from the data
|
|
249
|
+
if c.option == "Null":
|
|
250
|
+
null_w.value = "profile" if "profile" in c.recommended.lower() else "rotation"
|
|
251
|
+
if c.option == "Minimum lag":
|
|
252
|
+
lag_w.value = 1 if "min_lag = 1" in c.recommended else 0
|
|
253
|
+
from IPython.display import display as _d, HTML
|
|
254
|
+
_d(HTML(f"<b>Your data:</b> {len(obs)} observations, "
|
|
255
|
+
f"{sum(len(o.events) for o in obs)} events."))
|
|
256
|
+
_d(HTML("<b>What the tool recommends for your data</b>, and why (in plain "
|
|
257
|
+
"terms):<ul>"
|
|
258
|
+
+ "".join(f"<li style='margin-bottom:6px'>{c.plain}</li>"
|
|
259
|
+
for c in rec.choices) + "</ul>"))
|
|
260
|
+
_d(HTML("<details><summary>Show the technical rationale (for your methods "
|
|
261
|
+
"section)</summary><pre style='white-space:pre-wrap;font-size:88%'>"
|
|
262
|
+
+ "\n\n".join(f"[{c.option}] -> {c.recommended}\n{c.rationale}"
|
|
263
|
+
for c in rec.choices) + "</pre></details>"))
|
|
264
|
+
dup, cofrac = _data_quality(obs)
|
|
265
|
+
msg = (f"<hr><b>Data-quality check</b><br>{cofrac:.0%} of consecutive events "
|
|
266
|
+
"share a timestamp. This is <b>normal for video/frame-coded data</b> — "
|
|
267
|
+
"different actions coded at the same video frame (e.g. an interception "
|
|
268
|
+
"and the challenge that caused it) — and is <b>not an error</b>. The "
|
|
269
|
+
"recommended 'genuine lag' setting handles it correctly.")
|
|
270
|
+
if dup:
|
|
271
|
+
msg += (f"<br><b style='color:#b5342a'>Worth checking:</b> {dup} exact "
|
|
272
|
+
"duplicate row(s) (identical observation, event and time) — these "
|
|
273
|
+
"may be accidental double-entries.")
|
|
274
|
+
else:
|
|
275
|
+
msg += ("<br>No exact duplicate rows found — the shared timestamps are all "
|
|
276
|
+
"genuine co-occurrences.")
|
|
277
|
+
_d(HTML(msg))
|
|
278
|
+
settings_box.layout.display = "" # reveal step 3
|
|
279
|
+
|
|
280
|
+
def _colored_table(rows):
|
|
281
|
+
bg = {"r": "#e7f5ea", "b": "#fdf3e0", "c": "#f5f5f6"}
|
|
282
|
+
head = ["pattern", "times seen", "timing", "reliability (q, lower = stronger)",
|
|
283
|
+
"what it means"]
|
|
284
|
+
h = ["<table style='border-collapse:collapse;font-size:90%;width:100%'>",
|
|
285
|
+
"<tr>" + "".join(f"<th style='text-align:left;padding:5px 9px;"
|
|
286
|
+
f"border-bottom:2px solid #ccc'>{c}</th>" for c in head)
|
|
287
|
+
+ "</tr>"]
|
|
288
|
+
for r in rows:
|
|
289
|
+
q = r["fdr_q"]
|
|
290
|
+
v = "r" if q <= 0.05 else "b" if q <= 0.10 else "c"
|
|
291
|
+
cells = [r["pattern"], r["N"], r["critical_interval"], f"{q:.3g}",
|
|
292
|
+
r["interpretation"]]
|
|
293
|
+
h.append(f"<tr style='background:{bg[v]}'>"
|
|
294
|
+
+ "".join(f"<td style='padding:5px 9px;border-bottom:1px solid #eee'>"
|
|
295
|
+
f"{c}</td>" for c in cells) + "</tr>")
|
|
296
|
+
h.append("</table>")
|
|
297
|
+
return "".join(h)
|
|
298
|
+
|
|
299
|
+
def _on_run(_):
|
|
300
|
+
with results_out:
|
|
301
|
+
clear_output()
|
|
302
|
+
if not state.get("path"):
|
|
303
|
+
print("Upload a CSV and click Inspect & advise first."); return
|
|
304
|
+
from IPython.display import display as _d, HTML, Image
|
|
305
|
+
_d(HTML("<p>Running… calibration with many surrogates can take a minute.</p>"))
|
|
306
|
+
res = run_analysis(state["path"], observation=obs_c.value,
|
|
307
|
+
event=ev_c.value, start=st_c.value, time_unit=unit.value,
|
|
308
|
+
null=null_w.value, min_lag=lag_w.value, B=B_w.value)
|
|
309
|
+
globals()["last"] = res.calibration # tpattern.guided.last, for re-plotting
|
|
310
|
+
clear_output()
|
|
311
|
+
|
|
312
|
+
rows = patterns_table(res.calibration, ci_unit=res.ci_unit)
|
|
313
|
+
robust = sum(1 for r in rows if r["fdr_q"] <= 0.05)
|
|
314
|
+
border = sum(1 for r in rows if 0.05 < r["fdr_q"] <= 0.10)
|
|
315
|
+
chance = len(rows) - robust - border
|
|
316
|
+
_d(HTML(
|
|
317
|
+
f"<h3>Results</h3><p>The tool found <b>{res.n_detected}</b> candidate "
|
|
318
|
+
f"patterns and tested each against chance. <b>All {res.n_detected} are "
|
|
319
|
+
"listed below — nothing is removed</b>; the verdict tells you which to "
|
|
320
|
+
"trust:</p><ul>"
|
|
321
|
+
f"<li>🟢 <b>{robust} robust</b> — statistically reliable; safe to report.</li>"
|
|
322
|
+
f"<li>🟡 <b>{border} borderline</b> — near the threshold; treat with caution.</li>"
|
|
323
|
+
f"<li>⚪ <b>{chance} likely chance</b> — not distinguishable from random.</li>"
|
|
324
|
+
"</ul>"))
|
|
325
|
+
if robust == 0 and B_w.value < 2000:
|
|
326
|
+
_d(HTML(f"<p style='color:#b5342a'>Nothing reached 'robust' at "
|
|
327
|
+
f"B = {B_w.value} — borderline patterns need more surrogates. "
|
|
328
|
+
"Raise <b>surrogates B</b> toward 2000 and re-run before "
|
|
329
|
+
"concluding there is no signal.</p>"))
|
|
330
|
+
|
|
331
|
+
_d(HTML("<p style='color:#5b6270;font-size:90%'>How to read the table: each "
|
|
332
|
+
"row is a pattern. <b>q</b> is the risk it is a fluke — the "
|
|
333
|
+
"false-discovery rate (FDR); lower is stronger, and below 0.05 "
|
|
334
|
+
"counts as robust. Rows are coloured 🟢 reliable · "
|
|
335
|
+
"🟡 borderline · ⚪ likely chance.</p>"))
|
|
336
|
+
_d(HTML(_colored_table(rows))) # colour-coded results table
|
|
337
|
+
|
|
338
|
+
dpath = res.files.get("dendrograms")
|
|
339
|
+
if dpath and Path(dpath).exists():
|
|
340
|
+
_d(HTML("<b>Pattern dendrograms</b> (top patterns):"))
|
|
341
|
+
_d(Image(dpath))
|
|
342
|
+
|
|
343
|
+
_d(HTML("<details><summary><b>Customise the figures & table</b> "
|
|
344
|
+
"(click to expand)</summary><pre>"
|
|
345
|
+
"from tpattern import patterns_overview, pattern_dendrogram, patterns_table\n"
|
|
346
|
+
"import tpattern.guided as g\n"
|
|
347
|
+
"keep = [c.pattern for c in g.last.kept('fdr')] # surviving patterns\n"
|
|
348
|
+
f"patterns_overview(keep, 'dendrograms.png', ci_unit='{res.ci_unit}', max_rows=8)\n"
|
|
349
|
+
f"pattern_dendrogram(keep[0], title='my title', ci_unit='{res.ci_unit}', outfile='one.png')\n"
|
|
350
|
+
f"patterns_table(g.last, 'table.csv', ci_unit='{res.ci_unit}')</pre></details>"))
|
|
351
|
+
|
|
352
|
+
_d(HTML("<b>Methods</b> (paste into your write-up):"))
|
|
353
|
+
print(res.methods)
|
|
354
|
+
|
|
355
|
+
import shutil
|
|
356
|
+
zpath = shutil.make_archive(res.outdir, "zip", res.outdir)
|
|
357
|
+
try:
|
|
358
|
+
from google.colab import files # pragma: no cover
|
|
359
|
+
files.download(zpath)
|
|
360
|
+
except Exception:
|
|
361
|
+
print(f"\nAll outputs saved to {res.outdir}/ (download: {zpath})")
|
|
362
|
+
|
|
363
|
+
advise_btn.on_click(_on_advise)
|
|
364
|
+
run_btn.on_click(_on_run)
|
|
365
|
+
display(W.VBox([
|
|
366
|
+
W.HTML("<h3>tpattern — guided analysis</h3>"
|
|
367
|
+
"<p>Four steps: <b>upload</b> your events → <b>inspect</b> (see the "
|
|
368
|
+
"recommendation) → the recommended <b>settings</b> appear → <b>run</b>.</p>"),
|
|
369
|
+
upload, cols_info,
|
|
370
|
+
W.HTML("<b>Column names</b> (change if your export differs):"),
|
|
371
|
+
obs_c, ev_c, st_c, unit, unit_help,
|
|
372
|
+
advise_btn, advice_out, settings_box, results_out]))
|
tpattern/io.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reading event data into a sample of observations.
|
|
3
|
+
==================================================
|
|
4
|
+
|
|
5
|
+
The primary entry point is :func:`read_table`, which reads the canonical flat CSV
|
|
6
|
+
that any coding tool can export (one row per event: ``observation, event, start``;
|
|
7
|
+
see SCHEMA.md). This is what almost all users want::
|
|
8
|
+
|
|
9
|
+
from tpattern import read_table
|
|
10
|
+
obs = read_table("events.csv")
|
|
11
|
+
|
|
12
|
+
A T-pattern analysis runs over a *collection* of observations (a "sample"). Each
|
|
13
|
+
observation is one independent time-line (a possession, rally, bout, match);
|
|
14
|
+
patterns are counted across all observations, but a single pattern occurrence can
|
|
15
|
+
never straddle two of them. Several events may share a timestamp — the detection
|
|
16
|
+
engine treats co-timed events as an unordered set at that instant (see
|
|
17
|
+
``Config.min_lag`` to require a genuine lag instead).
|
|
18
|
+
|
|
19
|
+
:func:`read_sample` / :func:`read_observation` are a legacy reader for THEME's
|
|
20
|
+
tab-separated observation files, kept for compatibility: one file per observation,
|
|
21
|
+
``time<TAB>event`` rows, with ``:`` and ``&`` marking the observation window.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import csv
|
|
27
|
+
from collections import defaultdict
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# The two reserved symbols that delimit the observation window rather than
|
|
33
|
+
# denoting a real event.
|
|
34
|
+
START_MARKER = ":"
|
|
35
|
+
END_MARKER = "&"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Observation:
|
|
40
|
+
"""One time-line: the events of a single observation file.
|
|
41
|
+
|
|
42
|
+
Attributes
|
|
43
|
+
----------
|
|
44
|
+
name : str
|
|
45
|
+
File stem, used to trace patterns back to a specific shot/sequence.
|
|
46
|
+
start, end : int
|
|
47
|
+
The observation window [start, end] in milliseconds, taken from the
|
|
48
|
+
`:` and `&` markers. `T = end - start` is the window length used as the
|
|
49
|
+
denominator of the NX/T baseline probability.
|
|
50
|
+
events : list[tuple[int, str]]
|
|
51
|
+
(time, event_type) pairs, sorted by time, markers removed.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
start: int
|
|
56
|
+
end: int
|
|
57
|
+
events: list[tuple[int, str]] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def T(self) -> int:
|
|
61
|
+
"""Length of the observation window in time units (ms)."""
|
|
62
|
+
return self.end - self.start
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def read_observation(path: str | Path) -> Observation:
|
|
66
|
+
"""Parse a single THEME `.txt` file into an :class:`Observation`."""
|
|
67
|
+
path = Path(path)
|
|
68
|
+
start: int | None = None
|
|
69
|
+
end: int | None = None
|
|
70
|
+
events: list[tuple[int, str]] = []
|
|
71
|
+
|
|
72
|
+
for raw in path.read_text().splitlines():
|
|
73
|
+
raw = raw.strip()
|
|
74
|
+
if not raw:
|
|
75
|
+
continue
|
|
76
|
+
parts = raw.split("\t")
|
|
77
|
+
if len(parts) != 2:
|
|
78
|
+
# Tolerate whitespace-separated files as well.
|
|
79
|
+
parts = raw.split()
|
|
80
|
+
if len(parts) != 2:
|
|
81
|
+
continue
|
|
82
|
+
tok_time, tok_event = parts[0].strip(), parts[1].strip()
|
|
83
|
+
if tok_time.lower() == "time": # header row
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
t = int(tok_time)
|
|
87
|
+
except ValueError:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
if tok_event == START_MARKER:
|
|
91
|
+
start = t
|
|
92
|
+
elif tok_event == END_MARKER:
|
|
93
|
+
end = t
|
|
94
|
+
else:
|
|
95
|
+
events.append((t, tok_event))
|
|
96
|
+
|
|
97
|
+
events.sort(key=lambda e: e[0])
|
|
98
|
+
|
|
99
|
+
# Fall back gracefully if a marker is missing: use the event span.
|
|
100
|
+
if start is None:
|
|
101
|
+
start = events[0][0] if events else 0
|
|
102
|
+
if end is None:
|
|
103
|
+
end = events[-1][0] if events else start
|
|
104
|
+
|
|
105
|
+
return Observation(name=path.stem, start=start, end=end, events=events)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def read_sample(folder: str | Path, pattern: str = "*.txt") -> list[Observation]:
|
|
109
|
+
"""Read every observation file in a folder into a list (a "sample")."""
|
|
110
|
+
folder = Path(folder)
|
|
111
|
+
files = sorted(folder.glob(pattern))
|
|
112
|
+
if not files:
|
|
113
|
+
raise FileNotFoundError(f"No files matching {pattern!r} in {folder}")
|
|
114
|
+
return [read_observation(f) for f in files]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read_table(path: str | Path, *, observation: str = "observation",
|
|
118
|
+
event: str = "event", start: str = "start",
|
|
119
|
+
end: str | None = None, obs_start: str | None = None,
|
|
120
|
+
obs_end: str | None = None, build_event_from: list[str] | None = None,
|
|
121
|
+
time_unit: str = "s", sep: str = ",") -> list[Observation]:
|
|
122
|
+
"""Read the canonical flat event table (see SCHEMA.md) into a sample.
|
|
123
|
+
|
|
124
|
+
One row per event; rows are grouped by the `observation` column into one
|
|
125
|
+
:class:`Observation` each. This is the software-agnostic entry point — any
|
|
126
|
+
tool (OpenTag, Sportscode, a spreadsheet) exports to these columns and the
|
|
127
|
+
rest of the library is unchanged.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
observation, event, start : str
|
|
132
|
+
Column names for the required fields.
|
|
133
|
+
end : str, optional
|
|
134
|
+
Column giving each event's end time; if absent the observation window is
|
|
135
|
+
taken from the min/max event `start`.
|
|
136
|
+
obs_start, obs_end : str, optional
|
|
137
|
+
Columns giving the *observation window* (constant within an observation).
|
|
138
|
+
Supply these when the unit has real bounds that extend beyond its first and
|
|
139
|
+
last event — a possession, rally or bout usually does. The window length
|
|
140
|
+
T = obs_end - obs_start is the denominator of the NX/T baseline, so deriving
|
|
141
|
+
it from the events instead will shift every baseline probability. If absent,
|
|
142
|
+
the window falls back to the first/last event time.
|
|
143
|
+
build_event_from : list[str], optional
|
|
144
|
+
If given, the event code is built by joining these columns with '_'
|
|
145
|
+
(for coders who keep the code split across descriptor columns) instead of
|
|
146
|
+
reading `event` directly.
|
|
147
|
+
time_unit : {'s', 'ms'}
|
|
148
|
+
Unit of the time columns. Internally times are integer milliseconds, so
|
|
149
|
+
seconds are scaled by 1000 (the engine's baseline maths is unit-agnostic;
|
|
150
|
+
this only fixes the reported interval unit).
|
|
151
|
+
"""
|
|
152
|
+
if time_unit not in ("s", "ms"):
|
|
153
|
+
raise ValueError(f"time_unit must be 's' or 'ms', got {time_unit!r}")
|
|
154
|
+
path = Path(path)
|
|
155
|
+
scale = 1000 if time_unit == "s" else 1
|
|
156
|
+
rows_by_obs: dict[str, list] = defaultdict(list)
|
|
157
|
+
ends_by_obs: dict[str, list] = defaultdict(list)
|
|
158
|
+
window_by_obs: dict[str, tuple] = {}
|
|
159
|
+
|
|
160
|
+
with open(path, newline="") as fh:
|
|
161
|
+
reader = csv.DictReader(fh, delimiter=sep)
|
|
162
|
+
cols = set(reader.fieldnames or [])
|
|
163
|
+
# the event code comes from `event`, unless it is built from other columns
|
|
164
|
+
code_cols = set(build_event_from) if build_event_from else {event}
|
|
165
|
+
required = {observation, start} | code_cols
|
|
166
|
+
missing = required - cols
|
|
167
|
+
if missing:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"table {path.name} is missing required column(s) {sorted(missing)}. "
|
|
170
|
+
f"Found columns: {sorted(cols)}. Expected one row per event with at "
|
|
171
|
+
f"least '{observation}', '{event}' and '{start}' (rename via the "
|
|
172
|
+
f"read_table arguments if your export uses different names; see SCHEMA.md).")
|
|
173
|
+
for r in reader:
|
|
174
|
+
obs_id = (r.get(observation) or "").strip()
|
|
175
|
+
if not obs_id:
|
|
176
|
+
continue
|
|
177
|
+
if build_event_from:
|
|
178
|
+
# join only the non-empty parts, so an event with no value for one
|
|
179
|
+
# column (e.g. a Challenge with no outcome) yields "Challenge_Pressure",
|
|
180
|
+
# not "Challenge__Pressure", and one with none yields just its type.
|
|
181
|
+
parts = [(r.get(c) or "").strip() for c in build_event_from]
|
|
182
|
+
code = "_".join(p for p in parts if p)
|
|
183
|
+
else:
|
|
184
|
+
code = (r.get(event) or "").strip()
|
|
185
|
+
if not code:
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
t = int(round(float(r[start]) * scale))
|
|
189
|
+
except (ValueError, TypeError, KeyError):
|
|
190
|
+
continue
|
|
191
|
+
rows_by_obs[obs_id].append((t, code))
|
|
192
|
+
if end and r.get(end) not in (None, ""):
|
|
193
|
+
try:
|
|
194
|
+
ends_by_obs[obs_id].append(int(round(float(r[end]) * scale)))
|
|
195
|
+
except ValueError:
|
|
196
|
+
pass
|
|
197
|
+
if obs_start and obs_end and obs_id not in window_by_obs:
|
|
198
|
+
try:
|
|
199
|
+
window_by_obs[obs_id] = (
|
|
200
|
+
int(round(float(r[obs_start]) * scale)),
|
|
201
|
+
int(round(float(r[obs_end]) * scale)))
|
|
202
|
+
except (ValueError, TypeError, KeyError):
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
sample: list[Observation] = []
|
|
206
|
+
for obs_id, evs in rows_by_obs.items():
|
|
207
|
+
evs.sort(key=lambda e: e[0])
|
|
208
|
+
if obs_id in window_by_obs:
|
|
209
|
+
o_start, o_end = window_by_obs[obs_id]
|
|
210
|
+
else:
|
|
211
|
+
o_start = evs[0][0]
|
|
212
|
+
o_end = max(ends_by_obs[obs_id]) if ends_by_obs.get(obs_id) else evs[-1][0]
|
|
213
|
+
sample.append(Observation(name=obs_id, start=o_start, end=o_end, events=evs))
|
|
214
|
+
return sample
|