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.
Files changed (38) hide show
  1. {tpattern-0.1.10/tpattern.egg-info → tpattern-0.1.11}/PKG-INFO +1 -1
  2. {tpattern-0.1.10 → tpattern-0.1.11}/pyproject.toml +1 -1
  3. tpattern-0.1.11/tests/test_outputs.py +86 -0
  4. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/__init__.py +8 -4
  5. tpattern-0.1.11/tpattern/report.py +405 -0
  6. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/viz.py +126 -6
  7. {tpattern-0.1.10 → tpattern-0.1.11/tpattern.egg-info}/PKG-INFO +1 -1
  8. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern.egg-info/SOURCES.txt +1 -0
  9. tpattern-0.1.10/tpattern/report.py +0 -220
  10. {tpattern-0.1.10 → tpattern-0.1.11}/LICENSE +0 -0
  11. {tpattern-0.1.10 → tpattern-0.1.11}/README.md +0 -0
  12. {tpattern-0.1.10 → tpattern-0.1.11}/setup.cfg +0 -0
  13. {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_calibrated_pins.py +0 -0
  14. {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_contrast.py +0 -0
  15. {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_dedup.py +0 -0
  16. {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_guided.py +0 -0
  17. {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_null_compare.py +0 -0
  18. {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_read_table_validation.py +0 -0
  19. {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_report_dendrogram.py +0 -0
  20. {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_reproduction.py +0 -0
  21. {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_sofcoder.py +0 -0
  22. {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_support_reporting.py +0 -0
  23. {tpattern-0.1.10 → tpattern-0.1.11}/tests/test_synthetic.py +0 -0
  24. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/advisor.py +0 -0
  25. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/ci.py +0 -0
  26. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/contrast.py +0 -0
  27. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/detect.py +0 -0
  28. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/diagnostics.py +0 -0
  29. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/guided.py +0 -0
  30. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/io.py +0 -0
  31. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/methods.py +0 -0
  32. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/pattern.py +0 -0
  33. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/randomise.py +0 -0
  34. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/significance.py +0 -0
  35. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern/synthetic.py +0 -0
  36. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern.egg-info/dependency_links.txt +0 -0
  37. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern.egg-info/requires.txt +0 -0
  38. {tpattern-0.1.10 → tpattern-0.1.11}/tpattern.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tpattern
3
- Version: 0.1.10
3
+ Version: 0.1.11
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
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tpattern"
7
- version = "0.1.10"
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.10" # defined before submodule imports (methods.py reads it)
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="#34495e", lw=1.4)
45
- ax.plot([cx, x], [d, d], color="#34495e", lw=1.4)
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 critical interval on the join
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}]{ci_unit}", (x, d), textcoords="offset points",
51
- xytext=(0, 4), ha="center", fontsize=7, color="#c0392b")
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="#2980b9", ms=6)
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tpattern
3
- Version: 0.1.10
3
+ Version: 0.1.11
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
6
  License-Expression: MIT
@@ -6,6 +6,7 @@ tests/test_contrast.py
6
6
  tests/test_dedup.py
7
7
  tests/test_guided.py
8
8
  tests/test_null_compare.py
9
+ tests/test_outputs.py
9
10
  tests/test_read_table_validation.py
10
11
  tests/test_report_dendrogram.py
11
12
  tests/test_reproduction.py
@@ -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