AutoStatLib 0.4.8__tar.gz → 0.4.9__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.
- {autostatlib-0.4.8/src/AutoStatLib.egg-info → autostatlib-0.4.9}/PKG-INFO +1 -1
- {autostatlib-0.4.8 → autostatlib-0.4.9}/src/AutoStatLib/StatPlots.py +336 -6
- {autostatlib-0.4.8 → autostatlib-0.4.9}/src/AutoStatLib/_version.py +1 -1
- {autostatlib-0.4.8 → autostatlib-0.4.9/src/AutoStatLib.egg-info}/PKG-INFO +1 -1
- {autostatlib-0.4.8 → autostatlib-0.4.9}/tests/test_statplots.py +174 -1
- {autostatlib-0.4.8 → autostatlib-0.4.9}/LICENSE +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/MANIFEST.in +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/README.md +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/pyproject.toml +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/requirements.txt +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/setup.cfg +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/src/AutoStatLib/AutoStatLib.py +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/src/AutoStatLib/__init__.py +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/src/AutoStatLib/__main__.py +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/src/AutoStatLib/_protocol.py +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/src/AutoStatLib/helpers.py +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/src/AutoStatLib/normality_tests.py +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/src/AutoStatLib/statistical_tests.py +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/src/AutoStatLib/text_formatting.py +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/src/AutoStatLib.egg-info/SOURCES.txt +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/src/AutoStatLib.egg-info/dependency_links.txt +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/src/AutoStatLib.egg-info/requires.txt +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/src/AutoStatLib.egg-info/top_level.txt +0 -0
- {autostatlib-0.4.8 → autostatlib-0.4.9}/tests/test_autostatlib.py +0 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from typing import Literal, Optional
|
|
2
|
+
from typing import Callable, Literal, Optional
|
|
3
3
|
|
|
4
|
+
import matplotlib.artist
|
|
4
5
|
import matplotlib.axes
|
|
6
|
+
import matplotlib.collections
|
|
5
7
|
import matplotlib.colors as mcolors
|
|
6
8
|
import matplotlib.colors as color
|
|
7
9
|
import matplotlib.figure
|
|
@@ -10,6 +12,221 @@ import numpy as np
|
|
|
10
12
|
import seaborn as sns
|
|
11
13
|
|
|
12
14
|
|
|
15
|
+
# Dot overlay layouts accepted by the ``overlay`` argument of the plot classes.
|
|
16
|
+
OVERLAY_LAYOUTS: tuple[str, ...] = ("swarm", "honeycomb", "scatter")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _HoneycombLayout:
|
|
20
|
+
"""
|
|
21
|
+
Packs the dots of every group onto a honeycomb (hexagonal) lattice.
|
|
22
|
+
|
|
23
|
+
Every value is snapped to the nearest lattice row; dots that share a row
|
|
24
|
+
are placed side by side around the group centre, and neighbouring rows are
|
|
25
|
+
shifted by half a cell so the hexagons interlock and never overlap. All
|
|
26
|
+
dots start at the standard size; if the widest row of any group does not
|
|
27
|
+
fit into ``max_width`` the size of *all* dots is reduced until it does.
|
|
28
|
+
|
|
29
|
+
The packing depends on the final axes size and limits, which are only
|
|
30
|
+
known when the figure is rendered, so it runs lazily at draw time and is
|
|
31
|
+
recomputed whenever the axes transform changes (resize, zoom, save dpi).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
# Pointy-top hexagon with circumradius R: rows are 1.5 R apart and
|
|
35
|
+
# neighbours within a row are sqrt(3) R (flat-to-flat width) apart.
|
|
36
|
+
ROW_STEP: float = 1.5
|
|
37
|
+
COL_STEP: float = float(np.sqrt(3))
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
ax: matplotlib.axes.Axes,
|
|
42
|
+
groups: list[np.ndarray],
|
|
43
|
+
centers: list[float],
|
|
44
|
+
radius_pt: float,
|
|
45
|
+
max_width: float,
|
|
46
|
+
gap: float,
|
|
47
|
+
hexes: matplotlib.collections.PathCollection,
|
|
48
|
+
lines: Optional[matplotlib.collections.LineCollection] = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.ax = ax
|
|
51
|
+
self.groups = groups
|
|
52
|
+
self.centers = centers
|
|
53
|
+
self.max_radius_pt = radius_pt
|
|
54
|
+
self.max_width = max_width
|
|
55
|
+
self.gap = gap
|
|
56
|
+
self.hexes = hexes
|
|
57
|
+
self.lines = lines
|
|
58
|
+
# Lattice circumradius (in points) chosen by the last layout pass.
|
|
59
|
+
self.radius_pt: float = radius_pt
|
|
60
|
+
self._key: Optional[tuple] = None
|
|
61
|
+
|
|
62
|
+
# -- geometry helpers -------------------------------------------------- #
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _extent(counts: np.ndarray, parity: np.ndarray) -> np.ndarray:
|
|
66
|
+
"""Largest |offset| (in columns) of a row holding ``counts`` dots."""
|
|
67
|
+
return np.where(parity == 0, counts // 2, (counts - 1) // 2 + 0.5)
|
|
68
|
+
|
|
69
|
+
def _rows(self, y_px: np.ndarray, y_ref: float, r_px: float) -> tuple:
|
|
70
|
+
"""
|
|
71
|
+
Snap values to lattice rows and choose each row's half-cell shift.
|
|
72
|
+
|
|
73
|
+
Directly adjacent rows must alternate their shift to interlock. Each
|
|
74
|
+
block of adjacent occupied rows may start with either shift, so the
|
|
75
|
+
one giving the narrower (more centred) block is chosen.
|
|
76
|
+
"""
|
|
77
|
+
k = np.rint((y_px - y_ref) / (self.ROW_STEP * r_px)).astype(np.int64)
|
|
78
|
+
rows, inverse, counts = np.unique(k, return_inverse=True, return_counts=True)
|
|
79
|
+
new_block = np.r_[True, np.diff(rows) > 1]
|
|
80
|
+
block_id = np.cumsum(new_block) - 1
|
|
81
|
+
block_start = np.flatnonzero(new_block)
|
|
82
|
+
pos_in_block = rows - rows[block_start][block_id]
|
|
83
|
+
worst = [
|
|
84
|
+
np.maximum.reduceat(self._extent(counts, (pos_in_block + q) % 2), block_start)
|
|
85
|
+
for q in (0, 1)
|
|
86
|
+
]
|
|
87
|
+
shift = (worst[1] < worst[0]).astype(np.int64)
|
|
88
|
+
parity = (pos_in_block + shift[block_id]) % 2
|
|
89
|
+
return k, rows, inverse.ravel(), self._extent(counts, parity), parity, block_id
|
|
90
|
+
|
|
91
|
+
def _fits(self, packs: list[tuple], y_ref: float, r_px: float) -> bool:
|
|
92
|
+
for y_px, _, half_px in packs:
|
|
93
|
+
if y_px.size:
|
|
94
|
+
extent = self._rows(y_px, y_ref, r_px)[3]
|
|
95
|
+
if (extent.max() + 0.5) * self.COL_STEP * r_px > half_px:
|
|
96
|
+
return False
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
def _place(
|
|
100
|
+
self, y_px: np.ndarray, xc_px: float, y_ref: float, r_px: float
|
|
101
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
102
|
+
"""Return display coordinates of the hexagon centres of one group."""
|
|
103
|
+
k, rows, inverse, _, parity, block_id = self._rows(y_px, y_ref, r_px)
|
|
104
|
+
row_px = self.ROW_STEP * r_px
|
|
105
|
+
|
|
106
|
+
# Rank of every dot inside its row (stable: keeps the input order).
|
|
107
|
+
order = np.argsort(inverse, kind="stable")
|
|
108
|
+
sorted_rows = inverse[order]
|
|
109
|
+
rank = np.empty_like(inverse)
|
|
110
|
+
rank[order] = np.arange(inverse.size) - np.searchsorted(sorted_rows, sorted_rows)
|
|
111
|
+
|
|
112
|
+
# Fill rows from the centre outwards: 0, +1, -1, +2 ... for unshifted
|
|
113
|
+
# rows and +.5, -.5, +1.5 ... for shifted ones. The side that gets the
|
|
114
|
+
# odd dot out alternates every two rows to avoid a lopsided swarm.
|
|
115
|
+
p = parity[inverse]
|
|
116
|
+
side = np.where((rows[inverse] // 2) % 2 == 0, 1.0, -1.0)
|
|
117
|
+
magnitude = np.where(p == 0, (rank + 1) // 2, rank // 2 + 0.5)
|
|
118
|
+
first_side = np.where(p == 0, rank % 2 == 1, rank % 2 == 0)
|
|
119
|
+
offset = magnitude * np.where(first_side, side, -side)
|
|
120
|
+
|
|
121
|
+
x_px = xc_px + offset * self.COL_STEP * r_px
|
|
122
|
+
|
|
123
|
+
# Blocks of rows separated by empty rows need not share the lattice:
|
|
124
|
+
# centre each block on the range of its snapping errors (so isolated
|
|
125
|
+
# values, e.g. discrete levels, sit exactly at their value and no dot
|
|
126
|
+
# moves more than 0.75 R) while keeping >= 2 R between the facing rows
|
|
127
|
+
# of consecutive blocks, so they cannot touch.
|
|
128
|
+
y_lattice = y_ref + k * row_px
|
|
129
|
+
residual = y_px - y_lattice
|
|
130
|
+
point_block = block_id[inverse]
|
|
131
|
+
n_blocks = int(block_id[-1]) + 1
|
|
132
|
+
res_lo = np.full(n_blocks, np.inf)
|
|
133
|
+
res_hi = np.full(n_blocks, -np.inf)
|
|
134
|
+
np.minimum.at(res_lo, point_block, residual)
|
|
135
|
+
np.maximum.at(res_hi, point_block, residual)
|
|
136
|
+
block_shift = (res_lo + res_hi) / 2
|
|
137
|
+
block_first = np.flatnonzero(np.r_[True, np.diff(block_id) > 0])
|
|
138
|
+
block_last = np.r_[block_first[1:] - 1, rows.size - 1]
|
|
139
|
+
for b in range(1, n_blocks):
|
|
140
|
+
gap_px = (rows[block_first[b]] - rows[block_last[b - 1]]) * row_px
|
|
141
|
+
block_shift[b] = max(block_shift[b], block_shift[b - 1] + 2 * r_px - gap_px)
|
|
142
|
+
|
|
143
|
+
return x_px, y_lattice + block_shift[point_block]
|
|
144
|
+
|
|
145
|
+
# -- draw-time entry point ---------------------------------------------- #
|
|
146
|
+
|
|
147
|
+
def update(self, renderer) -> None:
|
|
148
|
+
trans = self.ax.transData
|
|
149
|
+
px_per_pt = float(renderer.points_to_pixels(1.0))
|
|
150
|
+
key = (
|
|
151
|
+
tuple(trans.transform([[1.0, 1.0], [2.0, 2.0]]).ravel()),
|
|
152
|
+
self.ax.get_xscale(),
|
|
153
|
+
self.ax.get_yscale(),
|
|
154
|
+
px_per_pt,
|
|
155
|
+
)
|
|
156
|
+
if key == self._key:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
packs: list[tuple] = []
|
|
160
|
+
for xc, vals in zip(self.centers, self.groups):
|
|
161
|
+
if not vals.size:
|
|
162
|
+
packs.append((vals, 0.0, 0.0))
|
|
163
|
+
continue
|
|
164
|
+
y_px = trans.transform(np.column_stack([np.full(vals.size, xc), vals]))[:, 1]
|
|
165
|
+
left, mid, right = trans.transform(
|
|
166
|
+
[[xc - self.max_width / 2, vals[0]], [xc, vals[0]], [xc + self.max_width / 2, vals[0]]]
|
|
167
|
+
)[:, 0]
|
|
168
|
+
packs.append((y_px, mid, min(abs(mid - left), abs(right - mid))))
|
|
169
|
+
|
|
170
|
+
y_all = np.concatenate([p[0] for p in packs])
|
|
171
|
+
half_all = [p[2] for p in packs if p[0].size]
|
|
172
|
+
if (
|
|
173
|
+
not y_all.size
|
|
174
|
+
or px_per_pt <= 0
|
|
175
|
+
or not np.all(np.isfinite(y_all))
|
|
176
|
+
or min(half_all) <= 0
|
|
177
|
+
or not np.all(np.isfinite(half_all))
|
|
178
|
+
):
|
|
179
|
+
return # degenerate axes (e.g. zero size) — keep the previous layout
|
|
180
|
+
self._key = key
|
|
181
|
+
y_ref = float(y_all.min())
|
|
182
|
+
|
|
183
|
+
# Largest circumradius not above the standard one that fits every group.
|
|
184
|
+
r_px = self.max_radius_pt * px_per_pt
|
|
185
|
+
if not self._fits(packs, y_ref, r_px):
|
|
186
|
+
n_max = max(p[0].size for p in packs)
|
|
187
|
+
# Even with every dot in one single row this radius fits:
|
|
188
|
+
lo = min(half_all) / (self.COL_STEP * (n_max / 2 + 0.5))
|
|
189
|
+
hi = r_px
|
|
190
|
+
while hi - lo > 1e-3 * hi:
|
|
191
|
+
mid_r = 0.5 * (lo + hi)
|
|
192
|
+
if self._fits(packs, y_ref, mid_r):
|
|
193
|
+
lo = mid_r
|
|
194
|
+
else:
|
|
195
|
+
hi = mid_r
|
|
196
|
+
r_px = lo
|
|
197
|
+
self.radius_pt = r_px / px_per_pt
|
|
198
|
+
|
|
199
|
+
placed_px = [
|
|
200
|
+
np.column_stack(self._place(y_px, xc_px, y_ref, r_px))
|
|
201
|
+
if y_px.size
|
|
202
|
+
else np.empty((0, 2))
|
|
203
|
+
for y_px, xc_px, _ in packs
|
|
204
|
+
]
|
|
205
|
+
placed = [trans.inverted().transform(xy) if xy.size else xy for xy in placed_px]
|
|
206
|
+
|
|
207
|
+
self.hexes.set_offsets(np.concatenate(placed))
|
|
208
|
+
self.hexes.set_sizes(np.array([(2 * self.radius_pt * (1 - self.gap)) ** 2]))
|
|
209
|
+
if self.lines is not None:
|
|
210
|
+
n_pairs = min(len(xy) for xy in placed)
|
|
211
|
+
self.lines.set_segments(
|
|
212
|
+
[np.array([xy[j] for xy in placed]) for j in range(n_pairs)]
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _run_before_draw(
|
|
217
|
+
artist: matplotlib.artist.Artist,
|
|
218
|
+
callback: Callable,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Call ``callback(renderer)`` every time ``artist`` is about to be drawn."""
|
|
221
|
+
original_draw = artist.draw
|
|
222
|
+
|
|
223
|
+
def draw(renderer) -> None:
|
|
224
|
+
callback(renderer)
|
|
225
|
+
original_draw(renderer)
|
|
226
|
+
|
|
227
|
+
setattr(artist, "draw", draw)
|
|
228
|
+
|
|
229
|
+
|
|
13
230
|
class Helpers:
|
|
14
231
|
|
|
15
232
|
def colors_to_rgba(
|
|
@@ -123,9 +340,16 @@ class BaseStatPlot(Helpers):
|
|
|
123
340
|
figure_scale_factor: float = 1,
|
|
124
341
|
figure_h: float = 4,
|
|
125
342
|
figure_w: float = 0, # 0 means auto
|
|
343
|
+
overlay: Optional[str] = None, # "swarm", "honeycomb", "scatter"; None = plot default
|
|
126
344
|
**kwargs,
|
|
127
345
|
) -> None:
|
|
128
346
|
|
|
347
|
+
if overlay is not None and overlay not in OVERLAY_LAYOUTS:
|
|
348
|
+
raise ValueError(
|
|
349
|
+
f"Unknown overlay {overlay!r}; expected one of {OVERLAY_LAYOUTS}"
|
|
350
|
+
)
|
|
351
|
+
self.overlay: Optional[str] = overlay
|
|
352
|
+
|
|
129
353
|
# Sanitise input data — replace empty groups with None; fall back to
|
|
130
354
|
# a dummy two-group dataset so the object is always in a valid state.
|
|
131
355
|
self.data_groups: list[list[float]] = (
|
|
@@ -599,6 +823,88 @@ class BaseStatPlot(Helpers):
|
|
|
599
823
|
zorder=zorder,
|
|
600
824
|
)
|
|
601
825
|
|
|
826
|
+
def add_honeycomb(
|
|
827
|
+
self,
|
|
828
|
+
ax: matplotlib.axes.Axes,
|
|
829
|
+
color: str = "dimgrey",
|
|
830
|
+
alpha: float = 1,
|
|
831
|
+
markersize: float = 8,
|
|
832
|
+
max_width: float = 0.8,
|
|
833
|
+
gap: float = 0.12,
|
|
834
|
+
linewidth: float = 1.4,
|
|
835
|
+
zorder: int = 2,
|
|
836
|
+
) -> _HoneycombLayout:
|
|
837
|
+
"""
|
|
838
|
+
Add a honeycomb overlay: every value is drawn as a hexagon packed
|
|
839
|
+
onto a hexagonal lattice, so the dots tile tightly and never overlap.
|
|
840
|
+
|
|
841
|
+
Values are snapped to the nearest lattice row (at most 3/4 of a
|
|
842
|
+
hexagon away from their true value). Hexagons keep the standard
|
|
843
|
+
``markersize`` (vertex-to-vertex, in points) unless the widest row of
|
|
844
|
+
a group would exceed ``max_width`` (in x-axis units, the distance
|
|
845
|
+
between group centres is 1); then all hexagons shrink equally until
|
|
846
|
+
every group fits. ``gap`` is the fraction of the size left empty
|
|
847
|
+
between neighbouring hexagons.
|
|
848
|
+
|
|
849
|
+
The layout is computed at draw time, so it stays correct after the
|
|
850
|
+
axes are resized, re-limited or saved at a different dpi.
|
|
851
|
+
Returns the layout object; its ``radius_pt`` holds the size in use
|
|
852
|
+
after the figure has been drawn.
|
|
853
|
+
"""
|
|
854
|
+
groups = [
|
|
855
|
+
np.asarray(g if g is not None else [], dtype=float).ravel()
|
|
856
|
+
for g in self.data_groups
|
|
857
|
+
]
|
|
858
|
+
groups = [g[np.isfinite(g)] for g in groups]
|
|
859
|
+
centers = [float(i) for i in range(len(groups))]
|
|
860
|
+
start_xy = np.concatenate(
|
|
861
|
+
[np.column_stack([np.full(g.size, c), g]) for c, g in zip(centers, groups)]
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
radius_pt = markersize * self.figure_scale_factor / 2
|
|
865
|
+
hexes = ax.scatter(
|
|
866
|
+
start_xy[:, 0],
|
|
867
|
+
start_xy[:, 1],
|
|
868
|
+
s=(2 * radius_pt * (1 - gap)) ** 2,
|
|
869
|
+
marker="h", # pointy-top hexagon: tiles in horizontal rows
|
|
870
|
+
color=color,
|
|
871
|
+
alpha=alpha,
|
|
872
|
+
linewidths=0,
|
|
873
|
+
zorder=zorder,
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
lines = None
|
|
877
|
+
if self.dependent:
|
|
878
|
+
lines = matplotlib.collections.LineCollection(
|
|
879
|
+
[],
|
|
880
|
+
colors=color,
|
|
881
|
+
alpha=alpha * 0.25,
|
|
882
|
+
linewidths=linewidth * self.figure_scale_factor,
|
|
883
|
+
zorder=zorder - 1,
|
|
884
|
+
)
|
|
885
|
+
ax.add_collection(lines, autolim=False)
|
|
886
|
+
|
|
887
|
+
layout = _HoneycombLayout(
|
|
888
|
+
ax, groups, centers, radius_pt, max_width, gap, hexes, lines
|
|
889
|
+
)
|
|
890
|
+
_run_before_draw(hexes, layout.update)
|
|
891
|
+
if lines is not None:
|
|
892
|
+
_run_before_draw(lines, layout.update)
|
|
893
|
+
|
|
894
|
+
# Same categorical x-range a seaborn swarm overlay would set.
|
|
895
|
+
ax.set_xlim(-0.5, len(groups) - 0.5)
|
|
896
|
+
return layout
|
|
897
|
+
|
|
898
|
+
def add_overlay(self, ax: matplotlib.axes.Axes, default: str = "swarm") -> None:
|
|
899
|
+
"""Draw the individual values using the layout chosen by ``overlay``."""
|
|
900
|
+
match self.overlay or default:
|
|
901
|
+
case "honeycomb":
|
|
902
|
+
self.add_honeycomb(ax)
|
|
903
|
+
case "scatter":
|
|
904
|
+
self.add_scatter(ax)
|
|
905
|
+
case _:
|
|
906
|
+
self.add_swarm(ax)
|
|
907
|
+
|
|
602
908
|
def add_errorbar_sd(
|
|
603
909
|
self,
|
|
604
910
|
ax: matplotlib.axes.Axes,
|
|
@@ -923,7 +1229,7 @@ class BarStatPlot(BaseStatPlot):
|
|
|
923
1229
|
self.add_median_marker(ax, x, linewidth=linewidth)
|
|
924
1230
|
self.add_mean_marker(ax, x, linewidth=linewidth)
|
|
925
1231
|
self.add_errorbar_sd(ax, x, linewidth=linewidth)
|
|
926
|
-
self.
|
|
1232
|
+
self.add_overlay(ax, default="swarm")
|
|
927
1233
|
self.add_significance_bars(ax, linewidth)
|
|
928
1234
|
self.add_titles_and_labels(fig, ax)
|
|
929
1235
|
self.axes_formatting(ax, linewidth)
|
|
@@ -950,7 +1256,7 @@ class ViolinStatPlot(BaseStatPlot):
|
|
|
950
1256
|
self.add_median_marker(ax, x, linewidth=linewidth)
|
|
951
1257
|
self.add_mean_marker(ax, x, linewidth=linewidth)
|
|
952
1258
|
self.add_errorbar_sd(ax, x, linewidth=linewidth)
|
|
953
|
-
self.
|
|
1259
|
+
self.add_overlay(ax, default="swarm")
|
|
954
1260
|
self.add_significance_bars(ax, linewidth)
|
|
955
1261
|
self.add_titles_and_labels(fig, ax)
|
|
956
1262
|
self.axes_formatting(ax, linewidth)
|
|
@@ -964,7 +1270,7 @@ class BoxStatPlot(BaseStatPlot):
|
|
|
964
1270
|
if not self.error:
|
|
965
1271
|
fig, ax = self.setup_figure()
|
|
966
1272
|
self.add_boxplot(ax)
|
|
967
|
-
self.
|
|
1273
|
+
self.add_overlay(ax, default="swarm")
|
|
968
1274
|
self.add_significance_bars(ax, linewidth)
|
|
969
1275
|
self.add_titles_and_labels(fig, ax)
|
|
970
1276
|
self.axes_formatting(ax, linewidth)
|
|
@@ -979,7 +1285,7 @@ class ScatterStatPlot(BaseStatPlot):
|
|
|
979
1285
|
self.add_median_marker(ax, x, linewidth=linewidth)
|
|
980
1286
|
self.add_mean_marker(ax, x, linewidth=linewidth)
|
|
981
1287
|
self.add_errorbar_sd(ax, x, linewidth=linewidth)
|
|
982
|
-
self.
|
|
1288
|
+
self.add_overlay(ax, default="scatter")
|
|
983
1289
|
self.add_significance_bars(ax, linewidth)
|
|
984
1290
|
self.add_titles_and_labels(fig, ax)
|
|
985
1291
|
self.axes_formatting(ax, linewidth)
|
|
@@ -996,7 +1302,31 @@ class SwarmStatPlot(BaseStatPlot):
|
|
|
996
1302
|
self.add_median_marker(ax, x, linewidth=linewidth)
|
|
997
1303
|
self.add_mean_marker(ax, x, linewidth=linewidth)
|
|
998
1304
|
self.add_errorbar_sd(ax, x, linewidth=linewidth)
|
|
999
|
-
self.
|
|
1305
|
+
self.add_overlay(ax, default="swarm")
|
|
1306
|
+
self.add_significance_bars(ax, linewidth)
|
|
1307
|
+
self.add_titles_and_labels(fig, ax)
|
|
1308
|
+
self.axes_formatting(ax, linewidth)
|
|
1309
|
+
xmin, xmax = ax.get_xlim()
|
|
1310
|
+
ax.set_xlim(xmin - 0.3, xmax + 0.3)
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
class HoneycombStatPlot(BaseStatPlot):
|
|
1314
|
+
"""
|
|
1315
|
+
Mean ± SD and median over a honeycomb overlay: each value is a hexagon
|
|
1316
|
+
packed onto a hexagonal lattice. Hexagons have a standard size and
|
|
1317
|
+
shrink uniformly only when needed to fit the widest group.
|
|
1318
|
+
The honeycomb overlay is also available on every other plot class via
|
|
1319
|
+
``overlay="honeycomb"``.
|
|
1320
|
+
"""
|
|
1321
|
+
|
|
1322
|
+
def plot(self, linewidth: float = 1.8) -> None: # type: ignore[override]
|
|
1323
|
+
if not self.error:
|
|
1324
|
+
fig, ax = self.setup_figure()
|
|
1325
|
+
for x in range(len(self.data_groups)):
|
|
1326
|
+
self.add_median_marker(ax, x, linewidth=linewidth)
|
|
1327
|
+
self.add_mean_marker(ax, x, linewidth=linewidth)
|
|
1328
|
+
self.add_errorbar_sd(ax, x, linewidth=linewidth)
|
|
1329
|
+
self.add_overlay(ax, default="honeycomb")
|
|
1000
1330
|
self.add_significance_bars(ax, linewidth)
|
|
1001
1331
|
self.add_titles_and_labels(fig, ax)
|
|
1002
1332
|
self.axes_formatting(ax, linewidth)
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# AutoStatLib package version:
|
|
2
|
-
__version__ = "0.4.
|
|
2
|
+
__version__ = "0.4.9"
|
|
@@ -21,6 +21,8 @@ from AutoStatLib.StatPlots import (
|
|
|
21
21
|
BarStatPlot,
|
|
22
22
|
BoxStatPlot,
|
|
23
23
|
Helpers,
|
|
24
|
+
HoneycombStatPlot,
|
|
25
|
+
OVERLAY_LAYOUTS,
|
|
24
26
|
ScatterStatPlot,
|
|
25
27
|
SwarmStatPlot,
|
|
26
28
|
SwarmStatPlot_subgrouping_betta,
|
|
@@ -33,6 +35,7 @@ ALL_PLOT_CLASSES = [
|
|
|
33
35
|
BoxStatPlot,
|
|
34
36
|
ScatterStatPlot,
|
|
35
37
|
SwarmStatPlot,
|
|
38
|
+
HoneycombStatPlot,
|
|
36
39
|
]
|
|
37
40
|
|
|
38
41
|
|
|
@@ -605,7 +608,177 @@ class TestSwarmSubgrouping:
|
|
|
605
608
|
|
|
606
609
|
|
|
607
610
|
# ─────────────────────────────────────────────
|
|
608
|
-
# 10.
|
|
611
|
+
# 10. Honeycomb overlay
|
|
612
|
+
# ─────────────────────────────────────────────
|
|
613
|
+
|
|
614
|
+
# Edge normals of a pointy-top hexagon (the 'h' marker).
|
|
615
|
+
_HEX_NORMALS = np.array([[np.cos(a), np.sin(a)] for a in np.radians([0, 60, 120])])
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _draw_honeycomb(data, figsize=None, **kwargs):
|
|
619
|
+
"""Plot a honeycomb overlay, render it and return (plot, ax, layout, hexes)."""
|
|
620
|
+
p = HoneycombStatPlot(data, **kwargs)
|
|
621
|
+
fig, ax = p.setup_figure()
|
|
622
|
+
if figsize is not None:
|
|
623
|
+
fig.set_size_inches(figsize)
|
|
624
|
+
layout = p.add_honeycomb(ax)
|
|
625
|
+
fig.canvas.draw()
|
|
626
|
+
return p, ax, layout, layout.hexes
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _hexagons_overlap(centers_px, radius_px):
|
|
630
|
+
"""
|
|
631
|
+
Exact overlap test for equal, equally oriented regular hexagons
|
|
632
|
+
(separating axis theorem): two of them are disjoint iff their centre
|
|
633
|
+
distance projected on one of the 3 edge normals is >= 2 * inradius.
|
|
634
|
+
Touching is allowed (that is how a honeycomb tiles).
|
|
635
|
+
"""
|
|
636
|
+
d = centers_px[:, None, :] - centers_px[None, :, :]
|
|
637
|
+
proj = np.abs(d @ _HEX_NORMALS.T).max(axis=-1)
|
|
638
|
+
np.fill_diagonal(proj, np.inf)
|
|
639
|
+
return bool((proj < np.sqrt(3) * radius_px * (1 - 1e-6)).any())
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _layout_px(ax, layout):
|
|
643
|
+
centers = ax.transData.transform(layout.hexes.get_offsets())
|
|
644
|
+
radius = layout.radius_pt * ax.figure.dpi / 72
|
|
645
|
+
return centers, radius
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
class TestHoneycomb:
|
|
649
|
+
|
|
650
|
+
@pytest.mark.parametrize("data", [
|
|
651
|
+
[list(np.random.default_rng(0).normal(0, 1, 200)),
|
|
652
|
+
list(np.random.default_rng(1).normal(1, 1, 150))],
|
|
653
|
+
[[0.0] * 40 + list(np.random.default_rng(2).gamma(1, 0.3, 40)),
|
|
654
|
+
list(np.random.default_rng(3).gamma(1, 0.5, 60))],
|
|
655
|
+
[[1, 2, 2, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 5]] * 3,
|
|
656
|
+
[list(np.random.default_rng(4).lognormal(0, 0.5, 600))],
|
|
657
|
+
])
|
|
658
|
+
def test_hexagons_never_overlap(self, data):
|
|
659
|
+
_, ax, layout, _ = _draw_honeycomb(data)
|
|
660
|
+
centers, radius = _layout_px(ax, layout)
|
|
661
|
+
assert len(centers) == sum(len(g) for g in data)
|
|
662
|
+
assert not _hexagons_overlap(centers, radius)
|
|
663
|
+
|
|
664
|
+
def test_standard_size_when_dots_fit(self, data2):
|
|
665
|
+
_, _, layout, _ = _draw_honeycomb(data2)
|
|
666
|
+
assert layout.radius_pt == pytest.approx(4.0) # markersize 8 / 2
|
|
667
|
+
|
|
668
|
+
def test_standard_size_follows_scale_factor(self, data2):
|
|
669
|
+
_, _, layout, _ = _draw_honeycomb(data2, figure_scale_factor=1.5)
|
|
670
|
+
assert layout.radius_pt == pytest.approx(6.0)
|
|
671
|
+
|
|
672
|
+
def test_size_shrinks_when_crowded(self):
|
|
673
|
+
_, ax, layout, _ = _draw_honeycomb([[5.0] * 200, [5.0] * 10])
|
|
674
|
+
assert layout.radius_pt < 4.0
|
|
675
|
+
# the 200 tied values form one row that still fits inside max_width
|
|
676
|
+
centers, radius = _layout_px(ax, layout)
|
|
677
|
+
half_px = ax.transData.transform([[0.4, 5.0]])[0, 0] - ax.transData.transform([[0.0, 5.0]])[0, 0]
|
|
678
|
+
x0 = ax.transData.transform([[0.0, 5.0]])[0, 0]
|
|
679
|
+
group0 = centers[:200, 0]
|
|
680
|
+
assert np.abs(group0 - x0).max() + np.sqrt(3) / 2 * radius <= half_px * (1 + 1e-6)
|
|
681
|
+
assert not _hexagons_overlap(centers, radius)
|
|
682
|
+
|
|
683
|
+
def test_size_is_shared_by_all_groups(self):
|
|
684
|
+
_, _, layout, hexes = _draw_honeycomb([[5.0] * 200, [1.0, 2.0, 3.0]])
|
|
685
|
+
assert len(hexes.get_sizes()) == 1
|
|
686
|
+
|
|
687
|
+
def test_dots_stay_close_to_their_values(self):
|
|
688
|
+
rng = np.random.default_rng(5)
|
|
689
|
+
data = [list(rng.normal(0, 1, 300)), list(rng.normal(2, 1, 300))]
|
|
690
|
+
_, ax, layout, _ = _draw_honeycomb(data)
|
|
691
|
+
centers, radius = _layout_px(ax, layout)
|
|
692
|
+
true_px = ax.transData.transform(
|
|
693
|
+
np.column_stack([np.zeros(600), np.concatenate(data)])
|
|
694
|
+
)[:, 1]
|
|
695
|
+
assert np.abs(centers[:, 1] - true_px).max() <= 0.75 * radius + 1e-6
|
|
696
|
+
|
|
697
|
+
def test_discrete_levels_drawn_exactly(self):
|
|
698
|
+
data = [[1, 2, 2, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 5]] * 2
|
|
699
|
+
_, _, _, hexes = _draw_honeycomb(data)
|
|
700
|
+
y = hexes.get_offsets()[:, 1]
|
|
701
|
+
assert np.allclose(y, np.concatenate(data))
|
|
702
|
+
|
|
703
|
+
def test_dots_stay_in_their_group(self, data3):
|
|
704
|
+
_, _, _, hexes = _draw_honeycomb(data3)
|
|
705
|
+
x = hexes.get_offsets()[:, 0]
|
|
706
|
+
groups = np.repeat(np.arange(3), 20)
|
|
707
|
+
assert np.all(np.abs(x - groups) <= 0.4)
|
|
708
|
+
|
|
709
|
+
def test_layout_follows_figure_resize(self):
|
|
710
|
+
rng = np.random.default_rng(6)
|
|
711
|
+
data = [list(rng.normal(0, 1, 400))]
|
|
712
|
+
_, ax, layout, _ = _draw_honeycomb(data, figsize=(1.5, 4))
|
|
713
|
+
small = layout.radius_pt
|
|
714
|
+
ax.figure.set_size_inches(6, 4)
|
|
715
|
+
ax.figure.canvas.draw()
|
|
716
|
+
assert layout.radius_pt > small
|
|
717
|
+
assert not _hexagons_overlap(*_layout_px(ax, layout))
|
|
718
|
+
|
|
719
|
+
def test_size_independent_of_save_dpi(self, data2):
|
|
720
|
+
p, ax, layout, _ = _draw_honeycomb([[5.0] * 100, [1.0]])
|
|
721
|
+
r_screen = layout.radius_pt
|
|
722
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
|
|
723
|
+
path = f.name
|
|
724
|
+
try:
|
|
725
|
+
p.save(path, dpi=300)
|
|
726
|
+
assert layout.radius_pt == pytest.approx(r_screen, rel=1e-2)
|
|
727
|
+
finally:
|
|
728
|
+
os.unlink(path)
|
|
729
|
+
|
|
730
|
+
def test_paired_lines_connect_hexagons(self, data2):
|
|
731
|
+
_, _, layout, hexes = _draw_honeycomb(data2, Paired_Samples=True)
|
|
732
|
+
assert layout.lines is not None
|
|
733
|
+
segments = layout.lines.get_segments()
|
|
734
|
+
offsets = hexes.get_offsets()
|
|
735
|
+
assert len(segments) == 20
|
|
736
|
+
for j, seg in enumerate(segments):
|
|
737
|
+
assert np.allclose(seg[0], offsets[j])
|
|
738
|
+
assert np.allclose(seg[1], offsets[20 + j])
|
|
739
|
+
|
|
740
|
+
def test_no_lines_when_unpaired(self, data2):
|
|
741
|
+
_, _, layout, _ = _draw_honeycomb(data2)
|
|
742
|
+
assert layout.lines is None
|
|
743
|
+
|
|
744
|
+
def test_nan_values_are_skipped(self):
|
|
745
|
+
_, ax, layout, hexes = _draw_honeycomb([[1.0, np.nan, 2.0], [3.0, 4.0]])
|
|
746
|
+
assert len(hexes.get_offsets()) == 4
|
|
747
|
+
assert np.all(np.isfinite(hexes.get_offsets()))
|
|
748
|
+
|
|
749
|
+
@pytest.mark.parametrize("PlotClass", ALL_PLOT_CLASSES)
|
|
750
|
+
def test_overlay_option_on_every_plot(self, PlotClass, data2):
|
|
751
|
+
p = PlotClass(data2, p_value_exact=0.03, overlay="honeycomb")
|
|
752
|
+
p.plot()
|
|
753
|
+
plt.gcf().canvas.draw()
|
|
754
|
+
hexes = [c for c in plt.gca().collections
|
|
755
|
+
if len(c.get_offsets()) == 40]
|
|
756
|
+
assert hexes, "honeycomb dots not drawn"
|
|
757
|
+
|
|
758
|
+
def test_overlay_values(self):
|
|
759
|
+
assert set(OVERLAY_LAYOUTS) == {"swarm", "honeycomb", "scatter"}
|
|
760
|
+
|
|
761
|
+
def test_invalid_overlay_raises(self, data2):
|
|
762
|
+
with pytest.raises(ValueError):
|
|
763
|
+
BarStatPlot(data2, overlay="hexbin")
|
|
764
|
+
|
|
765
|
+
def test_honeycomb_class_uses_honeycomb_by_default(self, data2, monkeypatch):
|
|
766
|
+
called = []
|
|
767
|
+
monkeypatch.setattr(HoneycombStatPlot, "add_honeycomb",
|
|
768
|
+
lambda self, ax, **kw: called.append(True))
|
|
769
|
+
HoneycombStatPlot(data2).plot()
|
|
770
|
+
assert called
|
|
771
|
+
|
|
772
|
+
def test_honeycomb_class_overlay_can_be_overridden(self, data2, monkeypatch):
|
|
773
|
+
called = []
|
|
774
|
+
monkeypatch.setattr(HoneycombStatPlot, "add_honeycomb",
|
|
775
|
+
lambda self, ax, **kw: called.append(True))
|
|
776
|
+
HoneycombStatPlot(data2, overlay="swarm").plot()
|
|
777
|
+
assert not called
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
# ─────────────────────────────────────────────
|
|
781
|
+
# 11. Large groups & edge values
|
|
609
782
|
# ─────────────────────────────────────────────
|
|
610
783
|
|
|
611
784
|
class TestEdgeCases:
|
|
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
|