tpattern 0.1.4__tar.gz → 0.1.6__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.4/tpattern.egg-info → tpattern-0.1.6}/PKG-INFO +1 -1
- {tpattern-0.1.4 → tpattern-0.1.6}/pyproject.toml +1 -1
- tpattern-0.1.6/tests/test_null_compare.py +64 -0
- tpattern-0.1.6/tests/test_report_dendrogram.py +51 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/__init__.py +4 -3
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/advisor.py +6 -1
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/report.py +7 -2
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/significance.py +118 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/viz.py +56 -1
- {tpattern-0.1.4 → tpattern-0.1.6/tpattern.egg-info}/PKG-INFO +1 -1
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern.egg-info/SOURCES.txt +2 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/LICENSE +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/README.md +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/setup.cfg +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tests/test_calibrated_pins.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tests/test_contrast.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tests/test_dedup.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tests/test_guided.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tests/test_reproduction.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tests/test_support_reporting.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tests/test_synthetic.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/ci.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/contrast.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/detect.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/diagnostics.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/guided.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/io.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/methods.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/pattern.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/randomise.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern/synthetic.py +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern.egg-info/dependency_links.txt +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/tpattern.egg-info/requires.txt +0 -0
- {tpattern-0.1.4 → tpattern-0.1.6}/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.6"
|
|
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,64 @@
|
|
|
1
|
+
"""compare_nulls: profile vs rotation decomposition into core and shell."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from tpattern import (Config, compare_nulls, NullComparison, calibrate,
|
|
5
|
+
null_comparison_plot)
|
|
6
|
+
from tpattern.synthetic import SyntheticSpec, make_dataset
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _planted(frac=0.15, seed=7):
|
|
10
|
+
# a dataset with a genuine A->B coupling planted on top of background
|
|
11
|
+
obs, _ = make_dataset(SyntheticSpec(
|
|
12
|
+
n_seq=300, planted=[("A", 0), ("B", 1500)], plant_fraction=frac,
|
|
13
|
+
jitter=200, bg_rate=2.5, seed=seed))
|
|
14
|
+
return obs
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_compare_nulls_structure():
|
|
18
|
+
obs = _planted()
|
|
19
|
+
cfg = Config(min_lag=1)
|
|
20
|
+
cmp = compare_nulls(obs, cfg, nulls=("profile", "rotation"), B=100, seed=1)
|
|
21
|
+
assert isinstance(cmp, NullComparison)
|
|
22
|
+
assert set(cmp.nulls) == {"profile", "rotation"}
|
|
23
|
+
# detection is null-independent: both nulls see the same detected patterns
|
|
24
|
+
assert set(cmp.detected["profile"]) == set(cmp.detected["rotation"])
|
|
25
|
+
# core is exactly the intersection of the two survivor sets
|
|
26
|
+
assert cmp.common == cmp.survives["profile"] & cmp.survives["rotation"]
|
|
27
|
+
# unique sets are disjoint from the core and from each other
|
|
28
|
+
assert cmp.unique["profile"].isdisjoint(cmp.common)
|
|
29
|
+
assert cmp.unique["rotation"].isdisjoint(cmp.common)
|
|
30
|
+
assert cmp.unique["profile"].isdisjoint(cmp.unique["rotation"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_rows_and_layers_consistent():
|
|
34
|
+
obs = _planted()
|
|
35
|
+
cmp = compare_nulls(obs, Config(min_lag=1), B=100, seed=2)
|
|
36
|
+
for r in cmp.rows():
|
|
37
|
+
surv = [n for n in cmp.nulls if r["survives"][n]]
|
|
38
|
+
assert surv, "a row must survive at least one null"
|
|
39
|
+
if len(surv) == len(cmp.nulls):
|
|
40
|
+
assert r["layer"] == "core"
|
|
41
|
+
elif len(surv) == 1:
|
|
42
|
+
assert r["layer"] == f"{surv[0]}-only"
|
|
43
|
+
# every listed q is a probability
|
|
44
|
+
for n in cmp.nulls:
|
|
45
|
+
q = r["q"][n]
|
|
46
|
+
assert q is None or 0.0 <= q <= 1.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_matches_individual_calibrate():
|
|
50
|
+
# compare_nulls must agree with calling calibrate() per null
|
|
51
|
+
obs = _planted()
|
|
52
|
+
cfg = Config(min_lag=1)
|
|
53
|
+
cmp = compare_nulls(obs, cfg, nulls=("profile", "rotation"), B=100, seed=3)
|
|
54
|
+
for null in ("profile", "rotation"):
|
|
55
|
+
r = calibrate(obs, cfg, null=null, B=100, seed=3)
|
|
56
|
+
assert cmp.survives[null] == {c.pattern.signature() for c in r.kept("fdr")}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_plot_writes_file(tmp_path):
|
|
60
|
+
obs = _planted()
|
|
61
|
+
cmp = compare_nulls(obs, Config(min_lag=1), B=100, seed=4)
|
|
62
|
+
out = tmp_path / "cmp.png"
|
|
63
|
+
null_comparison_plot(cmp, str(out), title="test")
|
|
64
|
+
assert out.exists() and out.stat().st_size > 0
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Regression: report()/patterns_overview must handle a single composite pattern.
|
|
2
|
+
|
|
3
|
+
With cols=2, plt.subplots returns a 2-axis ndarray even for one pattern; the old
|
|
4
|
+
code wrapped that array in a list and passed it to pattern_dendrogram, which then
|
|
5
|
+
called ndarray.plot(). The de-duplication work collapsed the quickstart to exactly
|
|
6
|
+
one composite, exposing the crash in report().
|
|
7
|
+
"""
|
|
8
|
+
import matplotlib
|
|
9
|
+
matplotlib.use("Agg")
|
|
10
|
+
|
|
11
|
+
from tpattern import Config, Engine, calibrate, report
|
|
12
|
+
from tpattern.viz import patterns_overview
|
|
13
|
+
from tpattern.synthetic import SyntheticSpec, make_dataset
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _planted():
|
|
17
|
+
obs, _ = make_dataset(SyntheticSpec(
|
|
18
|
+
n_seq=300, planted=[("A", 0), ("B", 1500)], plant_fraction=0.15,
|
|
19
|
+
jitter=200, bg_rate=2.5, seed=7))
|
|
20
|
+
return obs
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_patterns_overview_single_composite(tmp_path):
|
|
24
|
+
obs = _planted()
|
|
25
|
+
comps = [p for p in Engine(obs, Config(min_lag=1)).detect() if p.level >= 1]
|
|
26
|
+
assert comps, "planted data should yield at least one composite"
|
|
27
|
+
out = tmp_path / "ov.png"
|
|
28
|
+
patterns_overview(comps[:1], str(out)) # the n == 1 path
|
|
29
|
+
assert out.exists() and out.stat().st_size > 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_report_writes_dendrogram(tmp_path):
|
|
33
|
+
obs = _planted()
|
|
34
|
+
r = calibrate(obs, Config(min_lag=1), null="profile", B=50, seed=1)
|
|
35
|
+
import os
|
|
36
|
+
w = report(r, str(tmp_path / "out"), title="t")
|
|
37
|
+
assert os.path.exists(w["dendrograms"]) and os.path.getsize(w["dendrograms"]) > 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_forest_plot_degenerate_ci(tmp_path):
|
|
41
|
+
"""An odds-ratio CI can hit 0 or inf (a group with a zero cell); forest_plot
|
|
42
|
+
must not set a non-positive/inf limit on its log x-axis."""
|
|
43
|
+
import warnings
|
|
44
|
+
from tpattern import forest_plot
|
|
45
|
+
items = [{"label": "a", "or": 2.0, "lo": 0.0, "hi": float("inf")},
|
|
46
|
+
{"label": "b", "or": 1.5, "lo": 0.8, "hi": 3.0}]
|
|
47
|
+
out = tmp_path / "forest.png"
|
|
48
|
+
with warnings.catch_warnings():
|
|
49
|
+
warnings.simplefilter("error", UserWarning)
|
|
50
|
+
forest_plot(items, str(out))
|
|
51
|
+
assert out.exists() and out.stat().st_size > 0
|
|
@@ -6,16 +6,17 @@ 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.6" # defined before submodule imports (methods.py reads it)
|
|
10
10
|
|
|
11
11
|
from .io import Observation, read_observation, read_sample, read_table
|
|
12
12
|
from .pattern import Instance, Pattern
|
|
13
13
|
from .ci import find_critical_interval, CIResult
|
|
14
14
|
from .detect import Config, Engine
|
|
15
15
|
from .randomise import run_null, NullResult, rotate, shuffle
|
|
16
|
-
from .significance import calibrate, CalibrationResult
|
|
16
|
+
from .significance import calibrate, CalibrationResult, compare_nulls, NullComparison
|
|
17
17
|
from .report import patterns_table, forest_plot, report
|
|
18
|
-
from .viz import pattern_dendrogram, patterns_overview, support_profile
|
|
18
|
+
from .viz import (pattern_dendrogram, patterns_overview, support_profile,
|
|
19
|
+
null_comparison_plot)
|
|
19
20
|
from .advisor import recommend
|
|
20
21
|
from .methods import methods_text
|
|
21
22
|
from .guided import run_analysis, launch
|
|
@@ -109,7 +109,12 @@ def recommend(observations, *, min_count: int = 15,
|
|
|
109
109
|
f"(KS p<{uniformity_alpha}){ex_txt}, so the profile-preserving "
|
|
110
110
|
f"null (N2) was used to isolate cross-event coupling from each "
|
|
111
111
|
f"type's marginal timing; the rotation null (N1) is reported "
|
|
112
|
-
f"alongside to quantify the marginal-timing contribution."
|
|
112
|
+
f"alongside to quantify the marginal-timing contribution. The "
|
|
113
|
+
f"two nulls also pose different questions: N1 tests whether any "
|
|
114
|
+
f"temporal structure is present, N2 whether events are coupled "
|
|
115
|
+
f"beyond their own timing (the usual T-pattern claim), so N2 is "
|
|
116
|
+
f"recommended here; choose N1 if your question is only whether "
|
|
117
|
+
f"structure exists."),
|
|
113
118
|
plain=("<b>Testing patterns against chance — without being fooled by "
|
|
114
119
|
"timing.</b> Some actions naturally tend to happen at particular "
|
|
115
120
|
"points (for example, shots come late in a move), so two actions can "
|
|
@@ -141,10 +141,15 @@ def forest_plot(items, outfile: str, title: str = "Effect sizes (odds ratios)",
|
|
|
141
141
|
fig, ax = plt.subplots(figsize=(9, 0.6 * n + 1.4))
|
|
142
142
|
ys = list(range(n, 0, -1)) # top-to-bottom in given order
|
|
143
143
|
|
|
144
|
-
# fix x-range up front so label anchoring is stable on the log scale
|
|
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.
|
|
145
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]
|
|
146
150
|
ax.set_xscale("log")
|
|
147
|
-
ax.set_xlim(min(
|
|
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)
|
|
148
153
|
ax.set_ylim(0.3, n + 0.9)
|
|
149
154
|
# label in axes-x (fixed left/right), data-y
|
|
150
155
|
tx = blended_transform_factory(ax.transAxes, ax.transData)
|
|
@@ -224,3 +224,121 @@ def calibrate(observations, config: Config | None = None, *,
|
|
|
224
224
|
return CalibrationResult(real=real, surrogate_counts=surrogate_counts,
|
|
225
225
|
B=B, alpha=alpha, q_target=q_target, null=null,
|
|
226
226
|
n_observations=len(observations))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---- comparing nulls ---------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
@dataclass
|
|
232
|
+
class NullComparison:
|
|
233
|
+
"""Calibration of one sample under several surrogate nulls, and their difference.
|
|
234
|
+
|
|
235
|
+
The informative output is the DIFFERENCE between nulls, not either null alone.
|
|
236
|
+
With the profile and rotation nulls the surviving sets nest, and split into:
|
|
237
|
+
|
|
238
|
+
* the *core* -- patterns surviving under every null: the coupling (sequence)
|
|
239
|
+
claim, genuine coordination beyond each event type's own timing;
|
|
240
|
+
* the *shell* -- patterns surviving the rotation null but not the profile null:
|
|
241
|
+
co-occurrence driven by shared timing (two types that inhabit the same phase
|
|
242
|
+
of play) rather than by coordination.
|
|
243
|
+
|
|
244
|
+
The shell is a finding in its own right, not noise: it answers "which actions
|
|
245
|
+
occupy the same moment?" while the core answers "which actions are chained?".
|
|
246
|
+
Build one with compare_nulls().
|
|
247
|
+
"""
|
|
248
|
+
nulls: list
|
|
249
|
+
method: str
|
|
250
|
+
q_target: float
|
|
251
|
+
results: dict # null -> CalibrationResult
|
|
252
|
+
detected: dict # null -> {signature: Calibrated} (detection is null-independent)
|
|
253
|
+
survives: dict # null -> set(signature) surviving the correction
|
|
254
|
+
common: set # signatures surviving under ALL nulls (the core)
|
|
255
|
+
unique: dict # null -> set(signature) surviving under that null alone
|
|
256
|
+
|
|
257
|
+
def _q(self, null, sig):
|
|
258
|
+
c = self.detected[null].get(sig)
|
|
259
|
+
return None if c is None else c.fdr_q
|
|
260
|
+
|
|
261
|
+
def _N(self, sig):
|
|
262
|
+
for d in self.detected.values():
|
|
263
|
+
if sig in d:
|
|
264
|
+
return d[sig].N
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
def layer(self, sig):
|
|
268
|
+
"""Which layer a surviving pattern occupies: 'core', '<null>-only', or a join."""
|
|
269
|
+
surv = tuple(n for n in self.nulls if sig in self.survives[n])
|
|
270
|
+
if len(surv) == len(self.nulls):
|
|
271
|
+
return "core"
|
|
272
|
+
if len(surv) == 1:
|
|
273
|
+
return f"{surv[0]}-only"
|
|
274
|
+
return "+".join(surv) if surv else "none"
|
|
275
|
+
|
|
276
|
+
def rows(self):
|
|
277
|
+
"""One row per pattern surviving under at least one null, most significant first.
|
|
278
|
+
|
|
279
|
+
Each row: signature, N, layer, and per-null (survives, q). Tidy for a table.
|
|
280
|
+
"""
|
|
281
|
+
anysurv = set().union(*self.survives.values()) if self.survives else set()
|
|
282
|
+
sigs = sorted(anysurv, key=lambda s: min((self._q(n, s) or 1.0) for n in self.nulls))
|
|
283
|
+
return [{
|
|
284
|
+
"signature": s,
|
|
285
|
+
"N": self._N(s),
|
|
286
|
+
"layer": self.layer(s),
|
|
287
|
+
"survives": {n: (s in self.survives[n]) for n in self.nulls},
|
|
288
|
+
"q": {n: self._q(n, s) for n in self.nulls},
|
|
289
|
+
} for s in sigs]
|
|
290
|
+
|
|
291
|
+
def summary(self):
|
|
292
|
+
"""Short text summary that leads with the difference between nulls."""
|
|
293
|
+
pr = set(self.nulls) >= {"profile", "rotation"}
|
|
294
|
+
lines = [f"{n:10s}: {len(self.survives[n])} survive ({self.method.upper()} q<={self.q_target})"
|
|
295
|
+
for n in self.nulls]
|
|
296
|
+
lines.append(f"core (survives all{', the coupling claim' if pr else ''}): {len(self.common)}")
|
|
297
|
+
for n in self.nulls:
|
|
298
|
+
if self.unique[n]:
|
|
299
|
+
tag = " (shared-timing shell)" if (n == "rotation" and "profile" in self.nulls) else ""
|
|
300
|
+
lines.append(f"{n}-only{tag}: {len(self.unique[n])}")
|
|
301
|
+
return "\n".join(lines)
|
|
302
|
+
|
|
303
|
+
def __str__(self):
|
|
304
|
+
head = f"Null comparison [{', '.join(self.nulls)}]"
|
|
305
|
+
tab = [f" {'pattern':<50s} {'N':>4s} "
|
|
306
|
+
+ " ".join(f"{n[:8]:>9s}" for n in self.nulls) + " layer"]
|
|
307
|
+
for r in self.rows():
|
|
308
|
+
cells = " ".join(
|
|
309
|
+
((f"{r['q'][n]:.3f}" if r['q'][n] is not None else " - ")
|
|
310
|
+
+ ("*" if r['survives'][n] else " ")).rjust(9)
|
|
311
|
+
for n in self.nulls)
|
|
312
|
+
tab.append(f" {r['signature'][:50]:<50s} {r['N']:>4d} {cells} {r['layer']}")
|
|
313
|
+
return (head + "\n" + self.summary() + "\n\n" + "\n".join(tab)
|
|
314
|
+
+ "\n (* = survives the correction; q shown per null)")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def compare_nulls(observations, config=None, *, nulls=("profile", "rotation"),
|
|
318
|
+
B=200, alpha=0.005, q_target=0.05, method="fdr", seed=20260714):
|
|
319
|
+
"""Calibrate one sample under several nulls and decompose the survivors.
|
|
320
|
+
|
|
321
|
+
This is the 'both' option: it runs calibrate() once per null and reports, for
|
|
322
|
+
every pattern, which nulls it survives under, so the DIFFERENCE between nulls is
|
|
323
|
+
explicit. With the default profile-vs-rotation pair the result splits into a
|
|
324
|
+
coupling core (survives both) and a shared-timing shell (rotation-only); the
|
|
325
|
+
shell is a positive finding, not noise (see NullComparison).
|
|
326
|
+
|
|
327
|
+
Parameters mirror calibrate(); `nulls` is the tuple of nulls to compare and
|
|
328
|
+
`method` selects "fdr" or "fwer" for deciding survival. Detection is identical
|
|
329
|
+
across nulls, so only the calibration is repeated.
|
|
330
|
+
|
|
331
|
+
Returns a NullComparison: print it for a table, or use .rows()/.common/.unique.
|
|
332
|
+
"""
|
|
333
|
+
config = config or Config()
|
|
334
|
+
nulls = list(nulls)
|
|
335
|
+
results = {n: calibrate(observations, config, null=n, B=B, alpha=alpha,
|
|
336
|
+
q_target=q_target, seed=seed) for n in nulls}
|
|
337
|
+
detected = {n: {c.pattern.signature(): c for c in results[n].real} for n in nulls}
|
|
338
|
+
survives = {n: {c.pattern.signature() for c in results[n].kept(method)} for n in nulls}
|
|
339
|
+
common = set.intersection(*survives.values()) if survives else set()
|
|
340
|
+
unique = {n: survives[n] - set().union(*[survives[m] for m in nulls if m != n])
|
|
341
|
+
for n in nulls}
|
|
342
|
+
return NullComparison(nulls=nulls, method=method, q_target=q_target,
|
|
343
|
+
results=results, detected=detected, survives=survives,
|
|
344
|
+
common=common, unique=unique)
|
|
@@ -99,7 +99,9 @@ def patterns_overview(patterns: list, outfile: str, max_rows: int = 8,
|
|
|
99
99
|
cols = 2
|
|
100
100
|
rows = (n + cols - 1) // cols
|
|
101
101
|
fig, axes = plt.subplots(rows, cols, figsize=(12, 3.0 * rows))
|
|
102
|
-
|
|
102
|
+
# plt.subplots returns an ndarray whenever rows*cols > 1 (cols is always 2 here,
|
|
103
|
+
# so even a single pattern yields a 2-axis array); flatten it, else wrap the lone Axes.
|
|
104
|
+
axes = axes.flatten() if hasattr(axes, "flatten") else [axes]
|
|
103
105
|
for ax, p in zip(axes, comps):
|
|
104
106
|
pattern_dendrogram(p, title=f"N={p.N}, level {p.level}", ci_unit=ci_unit, ax=ax)
|
|
105
107
|
for ax in axes[n:]:
|
|
@@ -219,3 +221,56 @@ def support_profile(result, outfile: str, title: str | None = None,
|
|
|
219
221
|
fig.savefig(outfile)
|
|
220
222
|
plt.close(fig)
|
|
221
223
|
return [(sig, s) for sig, _, s in items]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def null_comparison_plot(comparison, outfile, title=None, max_rows=25):
|
|
227
|
+
"""Grid of which surrogate nulls each pattern survives, so the DIFFERENCE reads
|
|
228
|
+
at a glance. One row per pattern (surviving under at least one null), one column
|
|
229
|
+
per null; a filled cell means the pattern survives that null, annotated with its
|
|
230
|
+
q-value. Patterns are grouped core-first, then each null's unique (shell) set.
|
|
231
|
+
|
|
232
|
+
`comparison` is a NullComparison from compare_nulls(). Returns the rows plotted.
|
|
233
|
+
"""
|
|
234
|
+
nulls = comparison.nulls
|
|
235
|
+
# order: core first, then each null's unique set, most significant within group
|
|
236
|
+
def _minq(sig):
|
|
237
|
+
return min((comparison._q(n, sig) or 1.0) for n in nulls)
|
|
238
|
+
ordered = ([s for s in sorted(comparison.common, key=_minq)]
|
|
239
|
+
+ [s for n in nulls for s in sorted(comparison.unique[n], key=_minq)])
|
|
240
|
+
seen = set()
|
|
241
|
+
ordered = [s for s in ordered if not (s in seen or seen.add(s))][:max_rows]
|
|
242
|
+
if not ordered:
|
|
243
|
+
fig, ax = plt.subplots(figsize=(6, 2), dpi=200)
|
|
244
|
+
ax.text(0.5, 0.5, "No pattern survived under any null.", ha="center", va="center")
|
|
245
|
+
ax.axis("off"); fig.savefig(outfile); plt.close(fig); return []
|
|
246
|
+
|
|
247
|
+
GREEN, GREY, EDGE = "#2f7d4f", "#e4e7ec", "#333"
|
|
248
|
+
nrow, ncol = len(ordered), len(nulls)
|
|
249
|
+
fig, ax = plt.subplots(figsize=(3.0 + 1.5 * ncol, 1.1 + 0.42 * nrow), dpi=200)
|
|
250
|
+
for r, sig in enumerate(ordered):
|
|
251
|
+
y = nrow - 1 - r
|
|
252
|
+
layer = comparison.layer(sig)
|
|
253
|
+
for c, n in enumerate(nulls):
|
|
254
|
+
surv = sig in comparison.survives[n]
|
|
255
|
+
ax.add_patch(plt.Rectangle((c, y), 0.92, 0.92, facecolor=GREEN if surv else GREY,
|
|
256
|
+
edgecolor=EDGE, lw=0.6))
|
|
257
|
+
q = comparison._q(n, sig)
|
|
258
|
+
if q is not None:
|
|
259
|
+
ax.text(c + 0.46, y + 0.46, f"{q:.3f}", ha="center", va="center",
|
|
260
|
+
fontsize=7.5, color="white" if surv else "#8a93a3",
|
|
261
|
+
fontweight="bold" if surv else "normal")
|
|
262
|
+
ax.text(ncol + 0.15, y + 0.46, f"{sig}", ha="left", va="center", fontsize=7.4)
|
|
263
|
+
ax.text(-0.15, y + 0.46, layer, ha="right", va="center", fontsize=6.8,
|
|
264
|
+
color="#5b6270", style="italic")
|
|
265
|
+
ax.set_xlim(-2.4, ncol + 6.5); ax.set_ylim(-0.3, nrow + 0.2)
|
|
266
|
+
for c, n in enumerate(nulls):
|
|
267
|
+
ax.text(c + 0.46, nrow + 0.02, n, ha="center", va="bottom", fontsize=9, fontweight="bold")
|
|
268
|
+
ax.axis("off")
|
|
269
|
+
if title:
|
|
270
|
+
ax.set_title(title, fontsize=10.5, fontweight="bold", loc="left")
|
|
271
|
+
fig.text(0.5, 0.005, "filled = survives that null (q shown); core = survives all nulls, "
|
|
272
|
+
"shell = survives one null only", ha="center", fontsize=6.8, color="#8a93a3")
|
|
273
|
+
fig.tight_layout(rect=(0, 0.03, 1, 1))
|
|
274
|
+
fig.savefig(outfile)
|
|
275
|
+
plt.close(fig)
|
|
276
|
+
return ordered
|
|
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
|