AutoStatLib 0.4.7__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.
Files changed (24) hide show
  1. {autostatlib-0.4.7/src/AutoStatLib.egg-info → autostatlib-0.4.9}/PKG-INFO +2 -2
  2. {autostatlib-0.4.7 → autostatlib-0.4.9}/requirements.txt +1 -1
  3. {autostatlib-0.4.7 → autostatlib-0.4.9}/src/AutoStatLib/StatPlots.py +336 -6
  4. {autostatlib-0.4.7 → autostatlib-0.4.9}/src/AutoStatLib/_version.py +1 -1
  5. {autostatlib-0.4.7 → autostatlib-0.4.9/src/AutoStatLib.egg-info}/PKG-INFO +2 -2
  6. {autostatlib-0.4.7 → autostatlib-0.4.9}/src/AutoStatLib.egg-info/requires.txt +1 -1
  7. {autostatlib-0.4.7 → autostatlib-0.4.9}/tests/test_statplots.py +174 -1
  8. {autostatlib-0.4.7 → autostatlib-0.4.9}/LICENSE +0 -0
  9. {autostatlib-0.4.7 → autostatlib-0.4.9}/MANIFEST.in +0 -0
  10. {autostatlib-0.4.7 → autostatlib-0.4.9}/README.md +0 -0
  11. {autostatlib-0.4.7 → autostatlib-0.4.9}/pyproject.toml +0 -0
  12. {autostatlib-0.4.7 → autostatlib-0.4.9}/setup.cfg +0 -0
  13. {autostatlib-0.4.7 → autostatlib-0.4.9}/src/AutoStatLib/AutoStatLib.py +0 -0
  14. {autostatlib-0.4.7 → autostatlib-0.4.9}/src/AutoStatLib/__init__.py +0 -0
  15. {autostatlib-0.4.7 → autostatlib-0.4.9}/src/AutoStatLib/__main__.py +0 -0
  16. {autostatlib-0.4.7 → autostatlib-0.4.9}/src/AutoStatLib/_protocol.py +0 -0
  17. {autostatlib-0.4.7 → autostatlib-0.4.9}/src/AutoStatLib/helpers.py +0 -0
  18. {autostatlib-0.4.7 → autostatlib-0.4.9}/src/AutoStatLib/normality_tests.py +0 -0
  19. {autostatlib-0.4.7 → autostatlib-0.4.9}/src/AutoStatLib/statistical_tests.py +0 -0
  20. {autostatlib-0.4.7 → autostatlib-0.4.9}/src/AutoStatLib/text_formatting.py +0 -0
  21. {autostatlib-0.4.7 → autostatlib-0.4.9}/src/AutoStatLib.egg-info/SOURCES.txt +0 -0
  22. {autostatlib-0.4.7 → autostatlib-0.4.9}/src/AutoStatLib.egg-info/dependency_links.txt +0 -0
  23. {autostatlib-0.4.7 → autostatlib-0.4.9}/src/AutoStatLib.egg-info/top_level.txt +0 -0
  24. {autostatlib-0.4.7 → autostatlib-0.4.9}/tests/test_autostatlib.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AutoStatLib
3
- Version: 0.4.7
3
+ Version: 0.4.9
4
4
  Summary: AutoStatLib - a simple statistical analysis tool
5
5
  Author: Stemonitis, SciWare LLC
6
6
  Author-email: konung-yaropolk <yaropolk1995@gmail.com>
@@ -29,7 +29,7 @@ Requires-Dist: scipy>=1.17.0
29
29
  Requires-Dist: statsmodels
30
30
  Requires-Dist: matplotlib
31
31
  Requires-Dist: seaborn
32
- Requires-Dist: scikit-posthocs
32
+ Requires-Dist: scikit-posthocs>=0.16.1
33
33
  Requires-Dist: pandas
34
34
  Dynamic: license-file
35
35
 
@@ -3,5 +3,5 @@ scipy>=1.17.0
3
3
  statsmodels
4
4
  matplotlib
5
5
  seaborn
6
- scikit-posthocs
6
+ scikit-posthocs>=0.16.1
7
7
  pandas
@@ -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.add_swarm(ax)
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.add_swarm(ax)
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.add_swarm(ax)
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.add_scatter(ax)
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.add_swarm(ax)
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.7"
2
+ __version__ = "0.4.9"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AutoStatLib
3
- Version: 0.4.7
3
+ Version: 0.4.9
4
4
  Summary: AutoStatLib - a simple statistical analysis tool
5
5
  Author: Stemonitis, SciWare LLC
6
6
  Author-email: konung-yaropolk <yaropolk1995@gmail.com>
@@ -29,7 +29,7 @@ Requires-Dist: scipy>=1.17.0
29
29
  Requires-Dist: statsmodels
30
30
  Requires-Dist: matplotlib
31
31
  Requires-Dist: seaborn
32
- Requires-Dist: scikit-posthocs
32
+ Requires-Dist: scikit-posthocs>=0.16.1
33
33
  Requires-Dist: pandas
34
34
  Dynamic: license-file
35
35
 
@@ -3,5 +3,5 @@ scipy>=1.17.0
3
3
  statsmodels
4
4
  matplotlib
5
5
  seaborn
6
- scikit-posthocs
6
+ scikit-posthocs>=0.16.1
7
7
  pandas
@@ -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. Large groups & edge values
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