plot-io 0.12.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-x86_64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/kernels.py ADDED
@@ -0,0 +1,740 @@
1
+ """Numeric kernels in plot_io's C library (``plot_io/_native``), on arrays.
2
+
3
+ Arguments are sequences of numbers (``None`` for missing values) or any object exporting
4
+ the buffer protocol (``array.array``, ``memoryview``, numpy arrays, ...). Buffers are read
5
+ through :class:`memoryview` and converted in C; numpy is never imported. 1-D intake is
6
+ vec_io's :func:`vec_io.arrays.doubles` (re-exported as :func:`doubles`, with
7
+ :func:`rows`, :func:`grid` and :func:`buffer_shape` for 2-D and 3-D buffers).
8
+
9
+ :func:`scaled` (values through a function and an affine map), :func:`extent`,
10
+ :func:`quantiles`, :func:`box_stats`; binning (:func:`histogram`, :func:`bin_edges`,
11
+ :func:`histogram2d`, :func:`hexbin`, :func:`ecdf`), kernel densities (:func:`kde`,
12
+ :func:`bandwidth`; 2-D: :func:`kde2d_at`, :func:`kde2d_grid`, :func:`kde2d_bandwidth`),
13
+ contours (:func:`contour_lines`, :func:`contour_bands`), :func:`lowess`, :func:`swarm`,
14
+ :func:`argsort` and :func:`take`; hierarchical clustering (:func:`linkage`). Heatmap cells are mapped by :meth:`plot_io.ColorMap.cells`.
15
+ """
16
+
17
+ import array
18
+ import bisect
19
+ import ctypes
20
+ import math
21
+ import sys
22
+ from dataclasses import dataclass
23
+ from typing import Any
24
+
25
+ from vec_io import threads
26
+ from vec_io.arrays import buffer_shape, doubles, grid, rows
27
+
28
+ from ._native import kernels as _k
29
+
30
+
31
+ __all__ = ["doubles", "buffer_shape", "rows", "grid", "scaled", "extent", "quantiles",
32
+ "box_stats", "BoxStats", "histogram", "bin_edges", "histogram2d", "hexbin", "ecdf",
33
+ "kde", "bandwidth", "kde2d_bandwidth", "kde2d_at", "kde2d_grid", "contour_lines",
34
+ "contour_bands", "lowess", "swarm", "argsort", "take", "linkage", "LINKAGE_METHODS",
35
+ "LINKAGE_METRICS"]
36
+
37
+ def scaled(values: Any, scale: float = 1.0, offset: float = 0.0, function: str | None = None,
38
+ low: float | None = None, high: float | None = None, under: float | None = None,
39
+ over: float | None = None, nan: float | None = None,
40
+ linthresh: float = 1.0, below: float | None = None,
41
+ above: float | None = None) -> array.array:
42
+ """``scale * f(v) + offset`` for every value, as ``array('d')``.
43
+
44
+ ``function``: ``None``, ``"log"`` (natural), ``"log2"``, ``"log10"``, ``"sqrt"``,
45
+ ``"exp"``, ``"symlog"`` (``sign(v) log10(1 + |v| / linthresh)``) or ``"logit"``
46
+ (``log10(p / (1 - p))``, for 0 < p < 1). Missing values, values outside the function's domain and results that are not
47
+ finite become NaN, or ``nan`` when it is given. Other results below ``low`` become
48
+ ``under`` (default ``low``: clamped) and results above ``high`` become ``over`` (default
49
+ ``high``); NaN as ``under`` or ``over`` makes those values missing. ``below`` and
50
+ ``above``, when given, replace the results of finite values below the function's domain
51
+ (``log``: ≤ 0, ``sqrt``: < 0, ``logit``: ≤ 0) and above it (``logit``: ≥ 1, ``exp``:
52
+ overflowing)."""
53
+ low_ = -math.inf if low is None else float(low)
54
+ high_ = math.inf if high is None else float(high)
55
+ return _k.scaled(doubles(values), float(scale), float(offset), function, low_, high_,
56
+ low_ if under is None else float(under),
57
+ high_ if over is None else float(over),
58
+ None if nan is None else float(nan), float(linthresh),
59
+ None if below is None else float(below),
60
+ None if above is None else float(above))
61
+
62
+
63
+ def extent(values: Any, function: str | None = None) -> tuple[int, float, float]:
64
+ """``(count, min, max)`` of the finite values in the domain of ``function`` (names as in
65
+ :func:`scaled`); ``(0, nan, nan)`` when there are none."""
66
+ return _k.extent(doubles(values), function)
67
+
68
+
69
+ def quantiles(values: Any, qs: Any, weights: Any = None) -> list[float]:
70
+ """Quantiles ``qs`` (each in [0, 1]) of the finite values, by linear interpolation between
71
+ closest ranks (numpy's default); NaN for each when there are no finite values.
72
+
73
+ ``weights`` count as repeated values (integer weights give the quantiles of the values
74
+ repeated): Hmisc's ``wtd.quantile`` (type ``"quantile"``), positions ``1 + (n - 1) q``
75
+ on the cumulative weights, ``n`` their total."""
76
+ qs = [float(q) for q in qs]
77
+ if weights is None:
78
+ return _k.quantiles(doubles(values), qs)
79
+ if not all(0.0 <= q <= 1.0 for q in qs):
80
+ raise ValueError("each q must be in [0, 1]")
81
+ xs, cw = _weighted_sorted(values, weights)
82
+ return [_weighted_quantile(xs, cw, q) for q in qs]
83
+
84
+
85
+ def _weighted_sorted(values: Any, weights: Any) -> tuple[list[float], list[float]]:
86
+ """The finite values of positive weight, sorted, and their cumulative weights."""
87
+ v = doubles(values)
88
+ w = _weights(weights, len(v))
89
+ assert w is not None
90
+ xs: list[float] = []
91
+ cw: list[float] = []
92
+ total = 0.0
93
+ for i in argsort(v):
94
+ x, wi = v[i], w[i]
95
+ if wi > 0 and math.isfinite(x):
96
+ total += wi
97
+ xs.append(x)
98
+ cw.append(total)
99
+ return xs, cw
100
+
101
+
102
+ def _weighted_quantile(xs: list[float], cw: list[float], q: float) -> float:
103
+ """Quantile ``q`` of sorted values ``xs`` with cumulative weights ``cw``: the value at
104
+ (1-based) position ``r`` is the first whose cumulative weight reaches ``r``, positions
105
+ clamped to the ends; between the positions ``floor`` and ``floor + 1`` of
106
+ ``1 + (n - 1) q``, linear as :func:`quantiles` (integer weights: equal to the values
107
+ repeated)."""
108
+ if not xs:
109
+ return math.nan
110
+ n = cw[-1]
111
+
112
+ def at(r: float) -> float:
113
+ return xs[min(bisect.bisect_left(cw, r), len(xs) - 1)]
114
+
115
+ h = (n - 1.0) * q
116
+ lo = math.floor(h)
117
+ t = h - lo
118
+ if lo < 0: # a weight total below 1
119
+ order = 1.0 + h
120
+ a, b, t = at(1.0), at(min(2.0, n)), order % 1.0
121
+ return (1.0 - t) * a + t * b
122
+ a = at(lo + 1.0)
123
+ if lo + 1.0 >= n or t == 0:
124
+ return a
125
+ b = at(min(lo + 2.0, n))
126
+ d = b - a
127
+ return b - d * (1.0 - t) if t >= 0.5 else a + d * t
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class BoxStats:
132
+ """Box-plot statistics of the finite values (NaN fields when there are none)."""
133
+ count: float # with weights: their total
134
+ mean: float
135
+ q1: float
136
+ median: float
137
+ q3: float
138
+ low: float # whisker ends
139
+ high: float
140
+ outliers: array.array # values outside [low, high], in data order
141
+
142
+
143
+ def box_stats(values: Any, whiskers: Any = 1.5, weights: Any = None) -> BoxStats:
144
+ """Quartiles, median, mean, whisker ends and outliers of the finite values.
145
+
146
+ ``whiskers``: a number w (whiskers at the furthest data within w * IQR of the box),
147
+ ``"range"`` (the minimum and maximum) or ``(low, high)`` percentiles (0-100).
148
+ ``weights`` count as repeated values (quantiles as :func:`quantiles`); values of weight
149
+ 0 are left out, and each outlier is listed once."""
150
+ if isinstance(whiskers, str):
151
+ if whiskers != "range":
152
+ raise ValueError(f"whiskers must be a number, 'range' or (low, high), not {whiskers!r}")
153
+ mode, a, b = 1, 0.0, 0.0
154
+ elif isinstance(whiskers, (tuple, list)):
155
+ if len(whiskers) != 2:
156
+ raise ValueError("whisker percentiles must be a (low, high) pair")
157
+ mode, a, b = 2, float(whiskers[0]), float(whiskers[1])
158
+ else:
159
+ mode, a, b = 0, float(whiskers), 0.0
160
+ if weights is not None:
161
+ return _weighted_box_stats(values, weights, mode, a, b)
162
+ count, out, outliers = _k.box_stats(doubles(values), mode, a, b)
163
+ mean, q1, median, q3, low, high = out
164
+ return BoxStats(count, mean, q1, median, q3, low, high, outliers)
165
+
166
+
167
+ _SPLIT = 134217729.0 # 2^27 + 1
168
+
169
+
170
+ def _two_product(a: float, b: float) -> tuple[float, float]:
171
+ """``(p, e)`` with ``p + e == a * b`` exactly (Dekker; ``e`` 0 when not finite)."""
172
+ p = a * b
173
+ c = _SPLIT * a
174
+ ah = c - (c - a)
175
+ al = a - ah
176
+ c = _SPLIT * b
177
+ bh = c - (c - b)
178
+ bl = b - bh
179
+ e = ((ah * bh - p) + ah * bl + al * bh) + al * bl
180
+ return p, (e if math.isfinite(e) else 0.0)
181
+
182
+
183
+ def _weighted_box_stats(values: Any, weights: Any, mode: int, a: float, b: float) -> BoxStats:
184
+ if mode == 0 and not a >= 0:
185
+ raise ValueError("whiskers must be at least 0 IQR")
186
+ if mode == 2 and not 0 <= a <= b <= 100:
187
+ raise ValueError("whisker percentiles 0 <= low <= high <= 100")
188
+ v = doubles(values)
189
+ w = _weights(weights, len(v))
190
+ assert w is not None
191
+ xs, cw = _weighted_sorted(v, w)
192
+ nan = math.nan
193
+ if not xs:
194
+ return BoxStats(0, nan, nan, nan, nan, nan, nan, array.array("d"))
195
+ total = cw[-1]
196
+ parts: list[float] = [] # w x exactly, as two doubles each
197
+ for x, wi in zip(v, w):
198
+ if wi > 0 and math.isfinite(x):
199
+ parts += _two_product(wi, x)
200
+ mean = math.fsum(parts) / total
201
+ q1, median, q3 = (_weighted_quantile(xs, cw, q) for q in (0.25, 0.5, 0.75))
202
+ if mode == 1:
203
+ low, high = xs[0], xs[-1]
204
+ elif mode == 2:
205
+ low, high = _weighted_quantile(xs, cw, a / 100.0), _weighted_quantile(xs, cw, b / 100.0)
206
+ else:
207
+ iqr = q3 - q1
208
+ lo_fence, hi_fence = q1 - a * iqr, q3 + a * iqr
209
+ i = min(bisect.bisect_left(xs, lo_fence), len(xs) - 1)
210
+ j = max(bisect.bisect_right(xs, hi_fence) - 1, 0)
211
+ low, high = min(xs[i], q1), max(xs[j], q3)
212
+ outliers = array.array("d", (x for x, wi in zip(v, w)
213
+ if wi > 0 and math.isfinite(x) and (x < low or x > high)))
214
+ count = int(total) if total.is_integer() else total
215
+ return BoxStats(count, mean, q1, median, q3, low, high, outliers)
216
+
217
+
218
+ def _weights(weights: Any, n: int) -> array.array | None:
219
+ if weights is None:
220
+ return None
221
+ w = doubles(weights)
222
+ if len(w) != n:
223
+ raise ValueError(f"{len(w)} weights for {n} values")
224
+ if n:
225
+ total = sum(w)
226
+ if not math.isfinite(total) or min(w) < 0 or total <= 0:
227
+ raise ValueError("weights must be finite and non-negative, with a positive sum")
228
+ return w
229
+
230
+
231
+ def _edges(edges: Any) -> array.array:
232
+ e = doubles(edges)
233
+ if len(e) < 2:
234
+ raise ValueError("at least 2 bin edges are needed")
235
+ return e
236
+
237
+
238
+ def histogram(values: Any, edges: Any, weights: Any = None) -> array.array:
239
+ """Counts (sums of ``weights``) of the values in each bin between increasing ``edges``:
240
+ ``edges[k] <= v < edges[k + 1]``, the last bin closed, as numpy's ``histogram``. NaN
241
+ values and values outside the edges are not counted."""
242
+ v = doubles(values)
243
+ return _k.histogram(v, _edges(edges), _weights(weights, len(v)))
244
+
245
+
246
+ _RULES = ("auto", "fd", "sturges", "scott", "sqrt", "rice", "doane")
247
+
248
+ #: The most bins :func:`bin_edges` makes.
249
+ MAX_BINS = 1_000_000
250
+
251
+
252
+ def bin_edges(values: Any, rule: Any = "auto", range: Any = None) -> array.array:
253
+ """Even bin edges for ``values``: ``rule`` is a count of bins or one of numpy's rules
254
+ (``auto``, ``fd``, ``sturges``, ``scott``, ``sqrt``, ``rice``, ``doane``), over
255
+ ``range`` (default: the finite values' span). The rules give numpy's results; when every
256
+ finite value is a whole number, a rule's bin width is at least 1. Values outside ``range`` are left out of the rule; NaN values are ignored. A
257
+ rule asking for more than :data:`MAX_BINS` bins raises ``ValueError``, and so does a
258
+ count of bins narrower than the floats of the span can tell apart; a span that is not
259
+ finite gives one bin. Constant values span 0.5 on each side (more where floats are
260
+ coarser)."""
261
+ count_given = isinstance(rule, int) and not isinstance(rule, bool)
262
+ if count_given:
263
+ if not 1 <= rule <= MAX_BINS:
264
+ raise ValueError(f"bins must be between 1 and {MAX_BINS}, not {rule}")
265
+ elif rule not in _RULES:
266
+ raise ValueError(f"unknown bin rule {rule!r} (use {', '.join(_RULES)})")
267
+ v = doubles(values)
268
+ if range is not None:
269
+ first, last = float(range[0]), float(range[1])
270
+ if not (math.isfinite(first) and math.isfinite(last) and first < last):
271
+ raise ValueError(f"range must be finite (low, high) with low < high, not "
272
+ f"{tuple(range)}")
273
+ if not count_given:
274
+ v = scaled(v, 1.0, 0.0, None, first, last, math.nan, math.nan)
275
+ count, lo, hi = _k.extent(v, None)
276
+ if range is None:
277
+ if not count:
278
+ first, last = 0.0, 1.0
279
+ else:
280
+ first, last = lo, hi
281
+ constant = first == last
282
+ if constant: # wide enough for its bins to increase
283
+ half = max(0.5, (rule if count_given else 1) * 2.0 * math.ulp(first))
284
+ first, last = first - half, last + half
285
+ span = last - first
286
+ if not math.isfinite(span):
287
+ return array.array("d", [first, last])
288
+ n_bins = 1
289
+ if count_given:
290
+ n_bins = rule
291
+ elif count and not constant:
292
+ width = _bin_width(v, rule, count, hi - lo)
293
+ if width < 1 and all(map(float.is_integer, filter(math.isfinite, v))):
294
+ width = 1.0 # whole numbers: no bin narrower than 1
295
+ if width: # no bin narrower than the floats there
296
+ width = max(width, 4.0 * math.ulp(max(-first, last)))
297
+ if width and math.isfinite(width):
298
+ bins = math.ceil(span / width)
299
+ if bins > MAX_BINS:
300
+ raise ValueError(f"bins={rule!r} asks for {bins:.3g} bins (at most "
301
+ f"{MAX_BINS}); give a count or another rule")
302
+ n_bins = max(1, int(bins))
303
+ step = span / n_bins
304
+ edges = array.array("d", [first + i * step for i in _range(n_bins + 1)])
305
+ edges[-1] = last
306
+ if step < 4.0 * math.ulp(max(-first, last)) and any(
307
+ a >= b for a, b in zip(edges, edges[1:])):
308
+ raise ValueError(f"{n_bins} bins over the range {first!r} to {last!r} are "
309
+ f"narrower than its floats can tell apart; give fewer bins")
310
+ return edges
311
+
312
+
313
+ _range = range
314
+
315
+
316
+ def _bin_width(v: array.array, rule: str, n: int, ptp: float) -> float:
317
+ if rule == "sqrt":
318
+ return ptp / math.sqrt(n)
319
+ if rule == "sturges":
320
+ return ptp / (math.log2(n) + 1.0)
321
+ if rule == "rice":
322
+ return ptp / (2.0 * n ** (1.0 / 3))
323
+ if rule in ("scott", "doane"):
324
+ unit = 1.0
325
+ if ptp and not 2.0 ** -256 < ptp < 2.0 ** 256: # moments in a unit of about ptp
326
+ unit = math.ldexp(1.0, math.frexp(ptp)[1]) # (a power of 2: exact scaling)
327
+ v = scaled(v, 1.0 / unit)
328
+ _, sw, _, mean, m2, m3 = _k.moments(v, None)
329
+ sigma = math.sqrt(m2 / sw) if sw else 0.0
330
+ if rule == "scott":
331
+ return (24.0 * math.pi ** 0.5 / n) ** (1.0 / 3.0) * sigma * unit
332
+ if n > 2 and sigma > 0:
333
+ sg1 = math.sqrt(6.0 * (n - 2) / ((n + 1.0) * (n + 3)))
334
+ g1 = m3 / sw / sigma ** 3
335
+ return ptp / (1.0 + math.log2(n) + math.log2(1.0 + abs(g1) / sg1))
336
+ return 0.0
337
+ q75, q25 = _k.quantiles(v, [0.75, 0.25])
338
+ fd = 2.0 * (q75 - q25) * n ** (-1.0 / 3.0)
339
+ if rule == "fd":
340
+ return fd
341
+ sturges = ptp / (math.log2(n) + 1.0)
342
+ return min(max(fd, ptp / math.sqrt(n) / 2), sturges) # numpy 2: at most ~2 sqrt(n) bins
343
+
344
+
345
+ def histogram2d(x: Any, y: Any, x_edges: Any, y_edges: Any,
346
+ weights: Any = None) -> tuple[int, int, array.array]:
347
+ """2-D counts as ``(rows, columns, array('d'))``, rows over the y bins (bins as in
348
+ :func:`histogram`); pairs with a NaN are not counted."""
349
+ xv, yv = doubles(x), doubles(y)
350
+ if len(xv) != len(yv):
351
+ raise ValueError("x and y must have the same length")
352
+ xe, ye = _edges(x_edges), _edges(y_edges)
353
+ out = _k.histogram2d(xv, yv, _weights(weights, len(xv)), xe, ye)
354
+ return len(ye) - 1, len(xe) - 1, out
355
+
356
+
357
+ def hexbin(x: Any, y: Any, x0: float, y0: float, dx: float, dy: float, nx: int, ny: int,
358
+ weights: Any = None) -> tuple[array.array, array.array, array.array]:
359
+ """Hexagonal binning: ``(centres_x, centres_y, counts)`` of the non-empty cells.
360
+
361
+ The cells are those of two offset lattices: centres ``(x0 + i dx, y0 + j dy)`` and
362
+ ``(x0 + (i + 1/2) dx, y0 + (j + 1/2) dy)`` for ``i < nx``, ``j < ny``; each point goes to
363
+ the nearest centre. With ``dy = sqrt(3) dx`` the cells are regular hexagons of width
364
+ ``dx`` (corner to corner ``2 dx / sqrt(3)``), so pass coordinates in page units."""
365
+ xv, yv = doubles(x), doubles(y)
366
+ if len(xv) != len(yv):
367
+ raise ValueError("x and y must have the same length")
368
+ nx, ny = int(nx), int(ny)
369
+ counts = _k.hexbin(xv, yv, _weights(weights, len(xv)), float(x0), float(y0), float(dx),
370
+ float(dy), nx, ny)
371
+ cx, cy, cc = array.array("d"), array.array("d"), array.array("d")
372
+ cells = nx * ny
373
+ for k, c in enumerate(counts):
374
+ if c:
375
+ j, i = divmod(k % cells, nx)
376
+ off = 0.5 if k >= cells else 0.0
377
+ cx.append(x0 + (i + off) * dx)
378
+ cy.append(y0 + (j + off) * dy)
379
+ cc.append(c)
380
+ return cx, cy, cc
381
+
382
+
383
+ def ecdf(values: Any, weights: Any = None) -> tuple[array.array, array.array]:
384
+ """``(sorted values, cumulative weights)`` of the finite values (weights default to 1:
385
+ the cumulative counts); divide by the last for proportions."""
386
+ v = doubles(values)
387
+ return _k.ecdf(v, _weights(weights, len(v)))
388
+
389
+
390
+ def bandwidth(values: Any, rule: str = "scott", weights: Any = None) -> float:
391
+ """Gaussian kernel bandwidth by Scott's (``n^(-1/5)``) or Silverman's
392
+ (``(3n/4)^(-1/5)``) rule times the unbiased standard deviation, as scipy's
393
+ ``gaussian_kde``. Weights count as repeated values: ``n`` is their total. NaN when
394
+ fewer than 2 values (a weight total of at most 1)."""
395
+ if rule not in ("scott", "silverman"):
396
+ raise ValueError(f"unknown bandwidth rule {rule!r} (use 'scott' or 'silverman')")
397
+ v = doubles(values)
398
+ count, sw, _, _, m2, _ = _k.moments(v, _weights(weights, len(v)))
399
+ if count < 2 or not sw > 1:
400
+ return math.nan
401
+ var = m2 / (sw - 1)
402
+ factor = sw ** -0.2 if rule == "scott" else (sw * 0.75) ** -0.2
403
+ return factor * math.sqrt(var)
404
+
405
+
406
+ def kde(values: Any, grid_min: float, grid_max: float, n: int, bandwidth: float,
407
+ weights: Any = None, cumulative: bool = False) -> array.array:
408
+ """Gaussian kernel density of the finite values at ``n`` points evenly spaced over
409
+ ``[grid_min, grid_max]``; it integrates to 1 over the real line. ``cumulative``: the
410
+ integral from minus infinity instead. Each kernel is cut at 5 bandwidths, so the
411
+ density is 0 farther than that from every value. Direct sums when the kernel is narrow
412
+ against the grid spacing or the work is small; otherwise linear binning and convolution
413
+ (relative error below 1e-4 when the bandwidth is at least twice the spacing)."""
414
+ v = doubles(values)
415
+ return _k.kde(v, _weights(weights, len(v)), float(grid_min), float(grid_max), int(n),
416
+ float(bandwidth), cumulative)
417
+
418
+
419
+ _grid = grid
420
+
421
+
422
+ def _contour_grid(grid: Any, w: int, h: int, xs: Any, ys: Any):
423
+ shape = buffer_shape(grid)
424
+ if shape is not None and len(shape) == 2:
425
+ g = _grid(grid)[3]
426
+ elif not isinstance(grid, array.array) and grid is not None and len(grid) and \
427
+ not isinstance(grid[0], (int, float)) and grid[0] is not None:
428
+ g = array.array("d", [math.nan if v is None else float(v) for row in grid for v in row])
429
+ else:
430
+ g = doubles(grid)
431
+ w, h = int(w), int(h)
432
+ if w < 2 or h < 2 or len(g) != w * h:
433
+ raise ValueError(f"a grid of w x h values is needed (w, h >= 2; got {len(g)} for "
434
+ f"{w} x {h})")
435
+ xv = doubles(xs) if xs is not None else array.array("d", map(float, range(w)))
436
+ yv = doubles(ys) if ys is not None else array.array("d", map(float, range(h)))
437
+ if len(xv) != w or len(yv) != h:
438
+ raise ValueError("xs needs w values and ys h values (the node coordinates)")
439
+ return g, w, h, xv, yv
440
+
441
+
442
+ def contour_lines(grid: Any, w: int, h: int, xs: Any = None, ys: Any = None,
443
+ level: float = 0.0) -> list[tuple[array.array, array.array, bool]]:
444
+ """Contour lines of ``level`` on a ``w`` x ``h`` grid (rows over y; a 2-D buffer,
445
+ nested rows or ``w * h`` values) with nodes at ``xs[i]``, ``ys[j]``: a list of
446
+ ``(x, y, closed)``.
447
+
448
+ Marching squares, a node being above the level when ``v >= level``; saddles are resolved
449
+ by the cell's mean; cells with a NaN node are skipped (lines stop there)."""
450
+ g, w, h, xv, yv = _contour_grid(grid, w, h, xs, ys)
451
+ pts, starts, closed = _k.contour_lines(g, w, h, xv, yv, float(level))
452
+ lines = []
453
+ for k in range(len(closed)):
454
+ a, b = starts[k], starts[k + 1]
455
+ lines.append((pts[2 * a:2 * b:2], pts[2 * a + 1:2 * b:2], bool(closed[k])))
456
+ return lines
457
+
458
+
459
+ def contour_bands(grid: Any, w: int, h: int, xs: Any = None, ys: Any = None,
460
+ lo: float = -math.inf,
461
+ hi: float = math.inf) -> tuple[array.array, array.array, array.array]:
462
+ """The region ``lo <= v < hi`` of a grid (as :func:`contour_lines`) as its outline
463
+ ``(xs, ys, ring_starts)`` (``ring_starts`` holds each ring's first index plus the total):
464
+ closed rings with the band on their left, holes included, to fill together in one path
465
+ with the nonzero rule. The rings follow the contour lines of ``lo`` and ``hi`` and the
466
+ border of the data; cells with a NaN node are left out."""
467
+ g, w, h, xv, yv = _contour_grid(grid, w, h, xs, ys)
468
+ pts, starts = _k.contour_bands(g, w, h, xv, yv, float(lo), float(hi))
469
+ return pts[0::2], pts[1::2], starts
470
+
471
+
472
+ def lowess(x: Any, y: Any, frac: float = 2.0 / 3.0, iterations: int = 3,
473
+ delta: Any = "auto") -> array.array:
474
+ """LOWESS fit at each x (input order; NaN for pairs with a NaN): local linear fits over
475
+ the ``floor(frac n)`` nearest points (at least 2) of the ``n`` complete pairs, with
476
+ tricube weights as in statsmodels, then ``iterations`` robust passes with bisquare
477
+ weights of the residuals. As statsmodels' ``delta``, points within ``delta`` of the
478
+ last fitted x are interpolated instead of fitted; ``"auto"`` is 0 up to 10,000 pairs
479
+ and 1 % of the x range above."""
480
+ xv, yv = doubles(x), doubles(y)
481
+ if len(xv) != len(yv):
482
+ raise ValueError("x and y must have the same length")
483
+ if delta == "auto":
484
+ finite = [v for v, w in zip(xv, yv) if math.isfinite(v) and math.isfinite(w)]
485
+ delta = 0.01 * (max(finite) - min(finite)) if len(finite) > 10_000 else 0.0
486
+ return _k.lowess(xv, yv, float(frac), int(iterations), float(delta))
487
+
488
+
489
+ def swarm(positions: Any, size: Any, slot_width: float) -> tuple[array.array, int]:
490
+ """Swarm layout: offsets across the value axis (input order) for markers of diameter
491
+ ``size`` (one value or one per point) centred at ``positions``, so that none overlap,
492
+ and the number of points clamped to half the ``slot_width``. Use page units. NaN
493
+ positions get NaN offsets."""
494
+ p = doubles(positions)
495
+ if isinstance(size, (int, float)):
496
+ sizes, uniform = array.array("d", [float(size)]), True
497
+ else:
498
+ sizes, uniform = doubles(size), False
499
+ if len(sizes) != len(p):
500
+ raise ValueError(f"{len(sizes)} sizes for {len(p)} positions")
501
+ if not len(p):
502
+ return array.array("d"), 0
503
+ if len(p) < 20_000:
504
+ return _k.swarm(p, sizes, uniform, float(slot_width))
505
+ return _interruptible(_k.swarm, p, sizes, uniform, float(slot_width))
506
+
507
+
508
+ def _interruptible(fn: Any, *args: Any) -> Any:
509
+ """``fn(*args)`` (a native call that can run for seconds) on a thread while this one
510
+ waits, so that Ctrl-C interrupts the wait; the abandoned call ends on its own."""
511
+ import threading
512
+ result: list[Any] = []
513
+ error: list[BaseException] = []
514
+
515
+ def run() -> None:
516
+ try:
517
+ result.append(fn(*args))
518
+ except BaseException as e: # raised again on the waiting thread
519
+ error.append(e)
520
+
521
+ t = threading.Thread(target=run, name="plot_io-native", daemon=True)
522
+ t.start()
523
+ t.join()
524
+ if error:
525
+ raise error[0]
526
+ return result[0]
527
+
528
+
529
+ def _xy_weights(x: Any, y: Any, weights: Any) -> tuple[array.array, array.array,
530
+ array.array | None]:
531
+ xa, ya = doubles(x), doubles(y)
532
+ if len(xa) != len(ya):
533
+ raise ValueError("x and y must have the same length")
534
+ return xa, ya, _weights(weights, len(xa))
535
+
536
+
537
+ def kde2d_bandwidth(x: Any, y: Any, rule: str = "scott",
538
+ weights: Any = None) -> tuple[float, float, float]:
539
+ """Kernel covariance ``(h11, h12, h22)`` of a 2-D Gaussian density, as scipy's
540
+ ``gaussian_kde``: the unbiased data covariance times the squared factor ``n^(-1/6)``
541
+ (Scott's and Silverman's rules agree in 2-D). Weights count as repeated points: ``n``
542
+ is their total. A singular covariance (collinear points) falls back to an isotropic
543
+ kernel from the larger variance. NaN when fewer than 2 points (a weight total of at
544
+ most 1), or all equal."""
545
+ if rule not in ("scott", "silverman"):
546
+ raise ValueError(f"unknown bandwidth rule {rule!r} (use 'scott' or 'silverman')")
547
+ xa, ya, w = _xy_weights(x, y, weights)
548
+ count, sw, _, _, _, cxx, cyy, cxy = _k.moments2(xa, ya, w)
549
+ nan = (math.nan, math.nan, math.nan)
550
+ if count < 2 or not sw > 1:
551
+ return nan
552
+ d = sw - 1
553
+ vxx, vyy, vxy = cxx / d, cyy / d, cxy / d
554
+ f2 = sw ** (-1.0 / 3.0) # (n^(-1/6))^2
555
+ if not (vxx * vyy - vxy * vxy > 1e-12 * vxx * vyy) or vxx <= 0 or vyy <= 0:
556
+ v = max(vxx, vyy)
557
+ return (v * f2, 0.0, v * f2) if v > 0 else nan
558
+ return vxx * f2, vxy * f2, vyy * f2
559
+
560
+
561
+ def _kde2d_h(bandwidth: Any, x: array.array, y: array.array,
562
+ w: array.array | None) -> tuple[float, float, float]:
563
+ if isinstance(bandwidth, str):
564
+ return kde2d_bandwidth(x, y, bandwidth, w)
565
+ if isinstance(bandwidth, (int, float)):
566
+ s = float(bandwidth)
567
+ return s * s, 0.0, s * s
568
+ h = tuple(float(v) for v in bandwidth)
569
+ if len(h) != 3:
570
+ raise ValueError("bandwidth: a rule name, a standard deviation, or (h11, h12, h22)")
571
+ return h
572
+
573
+
574
+ #: Above this many points, ``method="auto"`` evaluates through the lattice.
575
+ KDE2D_EXACT_MAX = 20000
576
+
577
+
578
+ def kde2d_at(x: Any, y: Any, bandwidth: Any = "scott", method: str = "auto", grid: int = 512,
579
+ weights: Any = None) -> array.array:
580
+ """Gaussian kernel density of the points ``(x, y)`` at the points themselves, as
581
+ ``gaussian_kde(np.array([x, y]))(np.array([x, y]))``.
582
+
583
+ ``bandwidth``: a rule (``"scott"``, ``"silverman"``; see :func:`kde2d_bandwidth`), a
584
+ standard deviation (isotropic kernel), or a kernel covariance ``(h11, h12, h22)``.
585
+ ``method``: ``"exact"`` (direct sums on the worker threads; terms below ``e^-40`` of
586
+ their weight are left out), ``"grid"`` (a lattice of at least ``grid`` points per side,
587
+ up to 2048 to keep its spacing at most 0.1 kernel widths: relative error about 1e-4,
588
+ at most a few 1e-3) or ``"auto"`` (exact up to 20,000 points). Points with a missing
589
+ coordinate are not sources and get NaN."""
590
+ if method not in ("auto", "exact", "grid"):
591
+ raise ValueError(f"unknown method {method!r} (use 'auto', 'exact' or 'grid')")
592
+ xa, ya, w = _xy_weights(x, y, weights)
593
+ h = _kde2d_h(bandwidth, xa, ya, w)
594
+ if not all(math.isfinite(v) for v in h):
595
+ raise ValueError("kde2d_at: no bandwidth (fewer than 2 distinct points)")
596
+ n = len(xa)
597
+ exact = method == "exact" or (method == "auto" and n <= KDE2D_EXACT_MAX)
598
+ if not exact:
599
+ return _k.kde2d(xa, ya, w, h, xa, ya, int(grid))
600
+ step = 512
601
+ if n <= step:
602
+ return _k.kde2d(xa, ya, w, h, xa, ya)
603
+ parts = threads.map_ordered(
604
+ lambda i: _k.kde2d(xa, ya, w, h, xa[i:i + step], ya[i:i + step]),
605
+ range(0, n, step))
606
+ out = array.array("d")
607
+ for part in parts:
608
+ out.extend(part)
609
+ return out
610
+
611
+
612
+ def kde2d_grid(x: Any, y: Any, bandwidth: Any, x0: float, x1: float, nx: int, y0: float,
613
+ y1: float, ny: int, weights: Any = None, grid: int = 512) -> array.array:
614
+ """The density of :func:`kde2d_at` (through the lattice) on a regular ``ny`` x ``nx``
615
+ grid: row ``j`` at ``y0 + j (y1 - y0) / (ny - 1)``, column ``i`` likewise; row-major."""
616
+ xa, ya, w = _xy_weights(x, y, weights)
617
+ h = _kde2d_h(bandwidth, xa, ya, w)
618
+ if not all(math.isfinite(v) for v in h):
619
+ raise ValueError("kde2d_grid: no bandwidth (fewer than 2 distinct points)")
620
+ nx, ny = int(nx), int(ny)
621
+ if nx < 1 or ny < 1:
622
+ raise ValueError("kde2d_grid: nx and ny must be at least 1")
623
+ gx = [x0 + (x1 - x0) * i / (nx - 1) if nx > 1 else x0 for i in range(nx)]
624
+ gy = [y0 + (y1 - y0) * j / (ny - 1) if ny > 1 else y0 for j in range(ny)]
625
+ tx = array.array("d", gx * ny)
626
+ ty = array.array("d", (v for v in gy for _ in range(nx)))
627
+ return _k.kde2d(xa, ya, w, h, tx, ty, int(grid))
628
+
629
+
630
+ def argsort(values: Any, descending: bool = False) -> array.array:
631
+ """Indices that sort the values (``array('q')``), stable: ascending with NaN first, or
632
+ descending with NaN last; -0 equals 0."""
633
+ return _k.argsort(doubles(values), bool(descending))
634
+
635
+
636
+ _INTEGER_FORMATS = frozenset("bBhHiIlLqQnN")
637
+
638
+
639
+ def _numeric_view(value):
640
+ """A memoryview of ``value`` when it has a numeric buffer, else ``None``."""
641
+ if buffer_shape(value) is None:
642
+ return None
643
+ return memoryview(value)
644
+
645
+
646
+ def _indices(indices: Any) -> array.array:
647
+ """``indices`` as ``array('q')``; 8-byte integer buffers are read without a copy loop."""
648
+ if isinstance(indices, array.array) and indices.typecode == "q":
649
+ return indices
650
+ mv = _numeric_view(indices)
651
+ if mv is not None:
652
+ if mv.ndim != 1:
653
+ raise ValueError("take: indices must be 1-D")
654
+ fmt = mv.format.lstrip("@=<>!")
655
+ native = mv.format[0] not in "<>!" or (mv.format[0] == "<") == (sys.byteorder == "little")
656
+ if fmt in ("q", "l", "n") and mv.itemsize == 8 and native:
657
+ out = array.array("q")
658
+ out.frombytes(mv.cast("B") if mv.c_contiguous else mv.tobytes())
659
+ return out
660
+ if fmt in _INTEGER_FORMATS:
661
+ return array.array("q", mv.tolist())
662
+ raise TypeError("take: indices must be integers")
663
+ try:
664
+ return array.array("q", indices)
665
+ except TypeError:
666
+ raise TypeError("take: indices must be integers") from None
667
+ except OverflowError:
668
+ raise IndexError("take: index out of range") from None
669
+
670
+
671
+ def take(values: Any, indices: Any) -> array.array:
672
+ """``values[indices]`` as ``array('d')``, gathered in C.
673
+
674
+ ``values`` is anything :func:`doubles` accepts, or an ``(n, k)`` buffer of rows (e.g.
675
+ RGBA colors per point), in which case the result holds ``len(indices)`` rows of ``k``,
676
+ flat and row-major. ``indices`` are integers in ``[0, n)`` (``array('q')`` from
677
+ :func:`argsort`, numpy integer arrays, lists); others raise ``IndexError``."""
678
+ idx = _indices(indices)
679
+ mv = _numeric_view(values)
680
+ if mv is not None and mv.ndim in (1, 2):
681
+ k = mv.shape[1] if mv.ndim == 2 else 1
682
+ if k < 1:
683
+ raise ValueError("take: rows of at least one value")
684
+ native = mv.format[0] not in "<>!" or (mv.format[0] == "<") == (sys.byteorder == "little")
685
+ if (mv.format.lstrip("@=<>!") == "d" and native and mv.c_contiguous
686
+ and not mv.readonly and mv.nbytes):
687
+ # read in place: no copy of large inputs
688
+ holder = (ctypes.c_char * mv.nbytes).from_buffer(mv)
689
+ return _k.take_rows(None, k, idx, ctypes.addressof(holder), mv.shape[0])
690
+ flat = doubles(values) if mv.ndim == 1 else _grid(values)[3]
691
+ return _k.take_rows(flat, k, idx)
692
+ return _k.take_rows(doubles(values), 1, idx)
693
+
694
+
695
+ #: Methods and metrics of :func:`linkage`.
696
+ LINKAGE_METHODS = tuple(_k.LINKAGE_METHODS)
697
+ LINKAGE_METRICS = tuple(_k.LINKAGE_METRICS)
698
+
699
+
700
+ def linkage(data: Any, method: str = "average",
701
+ metric: str = "euclidean") -> tuple[int, array.array]:
702
+ """``(n, merges)``: scipy's ``linkage`` of the rows of ``data`` (a 2-D buffer or a
703
+ sequence of equal-length rows), or of a condensed distance vector (``metric``
704
+ "precomputed": the ``n(n - 1)/2`` distances ``d(i, j)``, ``i < j``, in row order).
705
+ ``merges`` holds ``n - 1`` rows of ``(left, right, height, size)``, flat: row ``i`` makes
706
+ cluster ``n + i``. Methods: :data:`LINKAGE_METHODS`; metrics: :data:`LINKAGE_METRICS`
707
+ (ward, centroid and median take euclidean or precomputed distances). Equal to scipy's
708
+ on input without equal distances; among equal distances the first found merges (scipy's
709
+ loops, lower indices first), the same on every platform. At most 16,384 observations."""
710
+ if method not in _k.LINKAGE_METHODS:
711
+ raise ValueError(f"linkage: unknown method {method!r} (use "
712
+ f"{', '.join(map(repr, LINKAGE_METHODS))})")
713
+ if metric not in _k.LINKAGE_METRICS:
714
+ raise ValueError(f"linkage: unknown metric {metric!r} (use "
715
+ f"{', '.join(map(repr, LINKAGE_METRICS))})")
716
+ if metric == "precomputed":
717
+ flat = doubles(data)
718
+ m = len(flat)
719
+ n = (1 + math.isqrt(1 + 8 * m)) // 2
720
+ if n * (n - 1) // 2 != m:
721
+ raise ValueError(f"linkage: {m} distances is not n(n - 1)/2 for any n")
722
+ d = 0
723
+ else:
724
+ g = _grid(data)
725
+ if g is not None:
726
+ if g[2] != 1:
727
+ raise ValueError("linkage: data must be 2-D (observations x values)")
728
+ n, d, flat = g[0], g[1], g[3]
729
+ else:
730
+ rs = [doubles(r) for r in data]
731
+ n = len(rs)
732
+ d = len(rs[0]) if rs else 0
733
+ if any(len(r) != d for r in rs):
734
+ raise ValueError("linkage: rows of different lengths")
735
+ flat = array.array("d")
736
+ for r in rs:
737
+ flat.extend(r)
738
+ if n > 1 and d < 1:
739
+ raise ValueError("linkage: rows of at least one value")
740
+ return n, _k.linkage(flat, n, max(d, 1), method, metric)