plot-io 0.12.0__py3-none-win_arm64.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-arm64.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
@@ -0,0 +1,1088 @@
1
+ """Stats: a call's data and settings → columns. A stat never draws.
2
+
3
+ ``bin`` (histograms), ``bin2d``, ``hexbin``, ``kde``, ``kde2d`` (a density grid),
4
+ ``ecdf``, ``box`` (quantiles and whiskers), ``violin`` (a density per category),
5
+ ``regression`` (polynomial or LOWESS, with a confidence band) and ``summary`` (an estimate
6
+ and an interval of replicates) are registered
7
+ (:func:`stat`): each takes the call's columns, the resolved settings it reads and the axis
8
+ scales, and returns plain columns; :func:`compute` runs one, cached by the series under
9
+ those values; ``series.stats`` exposes them. ``contour`` (iso-lines and bands) works on a
10
+ grid. Statistics on an axis are computed in the axis' space
11
+ (after its scale function): bins are even there and densities are per axis unit. The
12
+ genome stats (``region_signal``, ``intervals``, ``contact_matrix``) are in
13
+ :mod:`plot_io.genome` (``signal``, ``intervals``, ``contacts``). The kernels are plot_io's C library's
14
+ (:mod:`plot_io.kernels`).
15
+ """
16
+
17
+ import array
18
+ import math
19
+ import warnings
20
+ from typing import Any
21
+
22
+ from vec_io.arrays import doubles
23
+
24
+ from .. import kernels
25
+ from ..scales import get_scale
26
+ from ..validators import integer, real, typed
27
+
28
+ __all__ = ["STATS", "Stat", "Params", "stat", "stat_of", "params", "compute", "run",
29
+ "value_scale", "axis_scale", "scale_key",
30
+ "check_bins", "READS", "bin", "bin2d", "hexbin", "kde",
31
+ "kde_span", "kde2d", "ecdf", "box", "violin", "regression", "summary",
32
+ "interval_of", "contour",
33
+ "t_quantile", "norm_quantile", "beta_quantile", "density_at", "lowess"]
34
+
35
+ class Params(dict):
36
+ """The resolved settings a stat reads (a mapping, not changed by the stat), and the
37
+ type's name for messages (``kind``)."""
38
+
39
+ def __init__(self, kind: str, values: dict[str, Any]) -> None:
40
+ super().__init__(values)
41
+ self.kind = kind
42
+
43
+
44
+ class Stat:
45
+ """A registered stat: ``fn(columns, params, scales[, peers])``, the settings it
46
+ ``reads``, their ``defaults`` (for a type without the setting), and how a series gives
47
+ its ``scales`` and ``peers`` (functions of the series; :func:`run`)."""
48
+
49
+ __slots__ = ("name", "fn", "reads", "defaults", "scales", "peers")
50
+
51
+ def __init__(self, name: str, fn: Any, reads: tuple[str, ...],
52
+ defaults: dict[str, Any] | None = None, scales: Any = None,
53
+ peers: Any = None) -> None:
54
+ self.name, self.fn, self.reads = name, fn, reads
55
+ self.defaults = dict(defaults or {})
56
+ self.scales = scales
57
+ self.peers = peers
58
+
59
+ def __repr__(self) -> str:
60
+ return f"Stat({self.name!r})"
61
+
62
+
63
+ #: The registered stats by name.
64
+ STATS: dict[str, Stat] = {}
65
+
66
+
67
+ def stat(name: str, reads: tuple[str, ...] = (), defaults: dict[str, Any] | None = None,
68
+ scales: Any = None, peers: Any = None) -> Any:
69
+ """Register a stat ``fn(columns, params, scales, peers=None)``: the call's
70
+ :class:`~plot_io.columns.Columns`, the resolved values of the settings it ``reads``
71
+ (:class:`Params`; ``defaults``: the values of those a type does not have), the axis
72
+ scales it is computed on (in the axis' space; ``scales(series)`` gives them, default
73
+ none) and the plot-wide values it shares (``peers(series, scales)``, default none)."""
74
+ def add(fn: Any) -> Any:
75
+ STATS[name] = Stat(name, fn, reads, defaults, scales, peers)
76
+ return fn
77
+ return add
78
+
79
+
80
+ def stat_of(name: str) -> Stat:
81
+ """The registered stat ``name`` (a ``ValueError`` naming the registered ones)."""
82
+ st = STATS.get(name)
83
+ if st is None:
84
+ raise ValueError(f"unknown stat {name!r} (stats: {', '.join(sorted(STATS))})")
85
+ return st
86
+
87
+
88
+ def params(series: Any, name: str) -> Params:
89
+ """The params of stat ``name`` for ``series``: the settings it reads, resolved (the
90
+ stat's defaults for those the type does not have)."""
91
+ st = STATS[name]
92
+ have = series.SETTINGS
93
+ return Params(series.kind, {k: series.resolve(k) if k in have else st.defaults[k]
94
+ for k in st.reads if k in have or k in st.defaults})
95
+
96
+
97
+ def compute(series: Any, name: str, scales: tuple[Any, ...] = (), peers: Any = None,
98
+ kind: Any = "stats") -> Any:
99
+ """Stat ``name`` of ``series``, cached by the series under its params, the scales'
100
+ keys and ``peers`` (one result per ``kind``; ``None``: per key)."""
101
+ st = STATS[name]
102
+
103
+ def run() -> Any:
104
+ params_ = params(series, name)
105
+ if peers is None:
106
+ return st.fn(series.cols, params_, scales)
107
+ return st.fn(series.cols, params_, scales, peers)
108
+
109
+ key = (name, tuple(scale_key(sc) for sc in scales), peers)
110
+ return series.cached(key, run, st.reads, kind=kind)
111
+
112
+
113
+ def run(series: Any, name: str) -> Any:
114
+ """Stat ``name`` of ``series`` on the scales and peers the stat asks of it (cached, as
115
+ :func:`compute`): what a layer declaring the stat draws."""
116
+ st = stat_of(name)
117
+ scales = tuple(st.scales(series)) if st.scales is not None else ()
118
+ peers = st.peers(series, scales) if st.peers is not None else None
119
+ return compute(series, name, scales, peers)
120
+
121
+
122
+ def value_scale(series: Any) -> tuple[Any, ...]:
123
+ """The scale of the axis ``series``' values are drawn on (data role ``x``)."""
124
+ from .grammar import axis_of
125
+ return (axis_scale(axis_of(series, "x")),)
126
+
127
+
128
+ def _y_scale(series: Any) -> tuple[Any, ...]:
129
+ from .grammar import axis_of
130
+ return (axis_scale(axis_of(series, "y")),)
131
+
132
+
133
+ def _bin_scales(series: Any) -> tuple[Any, ...]:
134
+ """The scale the bins are even on (``bins_scale``), and the axis' scale."""
135
+ (unit,) = value_scale(series)
136
+ linear = "bins_scale" in series.SETTINGS and series.resolve("bins_scale") == "linear"
137
+ return (get_scale("linear") if linear else unit, unit)
138
+
139
+
140
+ def _stacked_peers(series: Any) -> list[Any]:
141
+ """The stacked series of ``series``' type in its plot, in its orientation (``[]``:
142
+ ``series`` is not stacked)."""
143
+ from .grammar import flipped
144
+ if "stack" not in series.SETTINGS or not series.resolve("stack"):
145
+ return []
146
+ flip = flipped(series)
147
+ return [k for k in series.plot.series if type(k) is type(series)
148
+ and k.resolve("stack") and flipped(k) == flip]
149
+
150
+
151
+ def _kde_peers(series: Any, scales: tuple[Any, ...]) -> tuple[float, float] | None:
152
+ """The grid stacked kde calls of the plot share: the union of their spans."""
153
+ members = _stacked_peers(series)
154
+ if not members:
155
+ return None
156
+ scale = scales[0]
157
+ spans = [k.cached(("span", scale_key(scale)),
158
+ lambda k=k: kde_span(k.cols, params(k, "kde"), scale), STATS["kde"].reads)
159
+ for k in members]
160
+ return min(a for a, _ in spans), max(b for _, b in spans)
161
+
162
+
163
+ def _bin_peers(series: Any, scales: tuple[Any, ...]) -> tuple[float, ...] | None:
164
+ """The bin edges stacked histograms of the plot share (several calls): the first
165
+ one's ``bins`` and ``range`` over the values of all."""
166
+ members = _stacked_peers(series)
167
+ if len(members) < 2:
168
+ return None
169
+ first = members[0]
170
+ if [scale_key(c) for c in scales] != [scale_key(c) for c in _bin_scales(first)]:
171
+ return None
172
+ p = params(first, "bin")
173
+ if isinstance(p["bins"], array.array):
174
+ return tuple(p["bins"])
175
+ scale = scales[0]
176
+ axis = "y" if p["orientation"] == "horizontal" else "x"
177
+ allv = array.array("d")
178
+ for k in members:
179
+ for _, v in k.cols.sets:
180
+ allv.extend(_f(v, scale))
181
+ fedges = kernels.bin_edges(allv, p["bins"], _range(p, p["range"], scale, axis))
182
+ return tuple(scale.inverse(e) for e in fedges)
183
+
184
+
185
+ #: The settings each stat reads (contour's: of the contour type's grid).
186
+ READS: dict[str, tuple[str, ...]] = {
187
+ "contour": ("levels", "lines", "fill", "thresh", "bandwidth", "adjust", "cut", "points"),
188
+ }
189
+
190
+
191
+ def axis_scale(axis: Any) -> Any:
192
+ """The scale of an axis as set (category axes: linear), for stats computed in the
193
+ axis' space (bins even on a log axis)."""
194
+ name = axis.nominal_scale
195
+ if name == "category":
196
+ name = "linear"
197
+ return get_scale(name, axis.resolve("linthresh") if name == "symlog" else None)
198
+
199
+
200
+ def scale_key(scale: Any) -> tuple:
201
+ """A scale's part of a cache key: its name and options."""
202
+ return (scale.name, tuple(sorted(scale.options.items())))
203
+
204
+
205
+ def _f(values: Any, scale: Any) -> Any:
206
+ """``values`` through the scale's function (NaN outside its domain)."""
207
+ if not scale.function:
208
+ return values
209
+ return kernels.scaled(values, 1.0, 0.0, scale.function, **scale.options)
210
+
211
+
212
+ @typed("SupportsFloat | "
213
+ "Literal['auto', 'fd', 'sturges', 'scott', 'sqrt', 'rice', 'doane'] | Column")
214
+ def check_bins(v: Any) -> Any:
215
+ """A bin count, a rule name, or increasing edges."""
216
+ if isinstance(v, str):
217
+ if v not in kernels._RULES:
218
+ raise ValueError(f"unknown bin rule {v!r} (use a count, edges or "
219
+ f"{', '.join(kernels._RULES)})")
220
+ return v
221
+ n = integer(v) if real(v) is not None else None
222
+ if n is not None:
223
+ if not 1 <= n <= kernels.MAX_BINS:
224
+ raise ValueError(f"bins must be between 1 and {kernels.MAX_BINS}")
225
+ return n
226
+ e = doubles(v)
227
+ if len(e) < 2 or any(not b > a for a, b in zip(e, e[1:])):
228
+ raise ValueError("bin edges must increase")
229
+ return e
230
+
231
+
232
+ def _range(s: Any, rng: Any, scale: Any, axis: str) -> tuple[float, float] | None:
233
+ """A ``range`` setting in the axis' space, checked."""
234
+ if rng is None:
235
+ return None
236
+ lo, hi = float(rng[0]), float(rng[1])
237
+ if not lo < hi:
238
+ raise ValueError(f"{s.kind}: range {tuple(rng)} must be (low, high) with low < high")
239
+ flo, fhi = scale.f(lo), scale.f(hi)
240
+ if not (math.isfinite(flo) and math.isfinite(fhi)):
241
+ raise ValueError(f"{s.kind}: range {tuple(rng)} is outside the {axis} axis' "
242
+ f"{scale.name} scale")
243
+ return flo, fhi
244
+
245
+
246
+ def _edges_in(s: Any, edges: Any, scale: Any, axis: str) -> array.array:
247
+ """Bin edges in the axis' space; edges outside the scale's domain are refused."""
248
+ out = array.array("d", (scale.f(e) for e in edges))
249
+ bad = [e for e, f in zip(edges, out) if not math.isfinite(f)]
250
+ if bad:
251
+ raise ValueError(f"{s.kind}: bin edge {bad[0]:g} is outside the {axis} axis' "
252
+ f"{scale.name} scale")
253
+ return out
254
+
255
+
256
+ def _weight(weights: Any, key: Any, i: int) -> Any:
257
+ if weights is None:
258
+ return None
259
+ if isinstance(weights, dict) and key not in weights:
260
+ raise ValueError(f"setting 'weights': no weights for set {key!r}")
261
+ return (weights[key] if isinstance(weights, dict) else
262
+ weights[i] if isinstance(weights, list) else weights)
263
+
264
+
265
+ def _category_weights(cols: Any, weights: Any) -> list[Any]:
266
+ """The weights of each category's values: a dict by category, a list in category
267
+ order, or one column (long data: a weight per row, grouped as the values)."""
268
+ n = len(cols.groups)
269
+ if weights is None:
270
+ return [None] * n
271
+ if isinstance(weights, dict):
272
+ missing = [k for k in cols.categories if k not in weights]
273
+ if missing:
274
+ raise ValueError(f"weights: no column for category {missing[0]!r}")
275
+ return [weights[k] for k in cols.categories]
276
+ if isinstance(weights, list):
277
+ if len(weights) != n:
278
+ raise ValueError(f"{len(weights)} weight columns for {n} categories")
279
+ return weights
280
+ key = cols.options.get("long")
281
+ if key is not None:
282
+ cats = cols.args[key]
283
+ if len(weights) != len(cats):
284
+ raise ValueError(f"{len(weights)} weights for {len(cats)} values")
285
+ from ..columns import Columns
286
+ by = Columns._long(weights, cats, cols.orders.get(key))
287
+ return [by[k] for k in cols.categories]
288
+ if n == 1:
289
+ return [weights]
290
+ raise ValueError(f"weights: one column per category ({n} categories), as the values")
291
+
292
+
293
+ def _kept(values: Any, weights: Any) -> tuple[Any, Any]:
294
+ """``(values, weights)`` without the rows of weight 0 (weights count as repeated rows),
295
+ the weights checked."""
296
+ if weights is None:
297
+ return values, None
298
+ v = doubles(values)
299
+ w = kernels._weights(weights, len(v))
300
+ assert w is not None
301
+ if min(w, default=1.0) > 0:
302
+ return v, w
303
+ keep = [i for i in range(len(v)) if w[i] > 0]
304
+ return kernels.take(v, keep), kernels.take(w, keep)
305
+
306
+
307
+ def _weighted_count(fv: Any, w: Any) -> float:
308
+ """The weight total of the finite values (an int when whole)."""
309
+ t = math.fsum(wi for x, wi in zip(fv, w) if math.isfinite(x))
310
+ return int(t) if t.is_integer() else t
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # 1-D
315
+ # ---------------------------------------------------------------------------
316
+
317
+ @stat("bin", reads=("bins", "range", "weights", "stat", "cumulative", "bins_scale", "orientation"),
318
+ defaults={"bins": "auto", "range": None, "weights": None, "stat": "count",
319
+ "cumulative": False, "bins_scale": "axis", "orientation": "vertical"},
320
+ scales=_bin_scales, peers=_bin_peers)
321
+ def bin(cols: Any, p: Params, scales: tuple[Any, ...],
322
+ edges: tuple[float, ...] | None = None) -> dict[str, Any]:
323
+ """``{"edges", "heights": [per set], "counts": [per set]}``: bins even on the first of
324
+ ``scales``; densities and frequencies per unit of the second (the axis' scale);
325
+ ``edges``: shared with other calls (stacked histograms), unless ``bins`` are edges."""
326
+ scale, ascale = scales
327
+ axis = "y" if p["orientation"] == "horizontal" else "x"
328
+ sets = cols.sets
329
+ fsets = [_f(v, scale) for _, v in sets]
330
+ bins = p["bins"]
331
+ frng = _range(p, p["range"], scale, axis)
332
+ if isinstance(bins, array.array):
333
+ fedges = _edges_in(p, bins, scale, axis)
334
+ elif edges is not None:
335
+ fedges = _edges_in(p, edges, scale, axis)
336
+ else:
337
+ if len(fsets) == 1:
338
+ allv = fsets[0]
339
+ else:
340
+ allv = array.array("d")
341
+ for v in fsets:
342
+ allv.extend(v)
343
+ fedges = kernels.bin_edges(allv, bins, frng)
344
+ out_edges = array.array("d", (scale.inverse(e) for e in fedges))
345
+ aedges = _edges_in(p, out_edges, ascale, axis) if ascale is not scale else fedges
346
+ widths = [b - a for a, b in zip(aedges, aedges[1:])]
347
+ weights = p["weights"]
348
+ st = p["stat"]
349
+ counts, heights = [], []
350
+ for i, ((key, _), fv) in enumerate(zip(sets, fsets)):
351
+ c = kernels.histogram(fv, fedges, _weight(weights, key, i))
352
+ counts.append(c)
353
+ total = math.fsum(c)
354
+ if st == "count":
355
+ hgt = list(c)
356
+ elif st == "probability":
357
+ hgt = [x / total if total else 0.0 for x in c]
358
+ elif st == "percent":
359
+ hgt = [100.0 * x / total if total else 0.0 for x in c]
360
+ elif st == "frequency":
361
+ hgt = [x / wd for x, wd in zip(c, widths)]
362
+ else:
363
+ hgt = [x / (total * wd) if total else 0.0 for x, wd in zip(c, widths)]
364
+ if p["cumulative"]:
365
+ acc, out = 0.0, []
366
+ for x, wd in zip(hgt, widths):
367
+ acc += x * wd if st in ("density", "frequency") else x
368
+ out.append(acc)
369
+ hgt = out
370
+ heights.append(array.array("d", hgt))
371
+ return {"edges": out_edges, "heights": heights, "counts": counts}
372
+
373
+
374
+ def _bandwidth(values: Any, rule: Any, weights: Any = None) -> float:
375
+ """A rule's bandwidth of ``values``, or a number given as is."""
376
+ if not isinstance(rule, str):
377
+ return float(rule)
378
+ return kernels.bandwidth(values, rule, weights)
379
+
380
+
381
+ def _kde_sets(cols: Any, p: Params, scale: Any) -> tuple[list[Any], list[Any], list[float], float, float]:
382
+ """``(f-space sets, weights, bandwidths, lo, hi)``: each set's bandwidth (NaN when it
383
+ has none) and the span of the data plus ``cut`` bandwidths, within ``range``."""
384
+ weights = p["weights"]
385
+ fsets, ws, bws = [], [], []
386
+ for i, (key, v) in enumerate(cols.sets):
387
+ v, w = _kept(v, _weight(weights, key, i))
388
+ fv = _f(v, scale)
389
+ n = kernels.extent(fv, None)[0]
390
+ bw = _bandwidth(fv, p["bandwidth"], w) * p["adjust"] if n else math.nan
391
+ fsets.append(fv)
392
+ ws.append(w)
393
+ bws.append(bw if math.isfinite(bw) and bw > 0 else math.nan)
394
+ cut = p["cut"]
395
+ lo, hi = math.inf, -math.inf
396
+ for fv, bw in zip(fsets, bws):
397
+ n, a, b = kernels.extent(fv, None)
398
+ if n:
399
+ pad = cut * bw if math.isfinite(bw) else 0.0
400
+ lo, hi = min(lo, a - pad), max(hi, b + pad)
401
+ if not math.isfinite(lo):
402
+ lo, hi = 0.0, 1.0
403
+ elif lo == hi:
404
+ lo, hi = lo - 0.5, hi + 0.5
405
+ axis = "y" if p["orientation"] == "horizontal" else "x"
406
+ frng = _range(p, p["range"], scale, axis)
407
+ if frng is not None:
408
+ lo, hi = max(lo, frng[0]), min(hi, frng[1])
409
+ if not lo < hi:
410
+ lo, hi = frng
411
+ bws = [math.nan] * len(bws)
412
+ return fsets, ws, bws, lo, hi
413
+
414
+
415
+ def kde_span(cols: Any, p: Params, scale: Any) -> tuple[float, float]:
416
+ """The span of a kde series' grid in the axis' space (before sharing a stack's)."""
417
+ return _kde_sets(cols, p, scale)[3:]
418
+
419
+
420
+ @stat("kde", reads=("bandwidth", "adjust", "cut", "range", "points", "cumulative", "weights", "stack", "orientation"),
421
+ defaults={"bandwidth": "scott", "adjust": 1.0, "cut": 3.0, "range": None, "points": 512,
422
+ "cumulative": False, "weights": None, "stack": False,
423
+ "orientation": "vertical"},
424
+ scales=value_scale, peers=_kde_peers)
425
+ def kde(cols: Any, p: Params, scales: tuple[Any, ...],
426
+ span: tuple[float, float] | None = None) -> dict[str, Any]:
427
+ """``{"grid", "densities": [per set], "bandwidths": [per set]}``: on a common grid
428
+ (over ``span`` in the axis' space when given), per unit of ``scale`` (log axes: per
429
+ log unit). A set without a bandwidth (no values, one, or all equal) or a ``range``
430
+ outside the data has no density (NaN), with a warning."""
431
+ scale = scales[0]
432
+ fsets, ws, bws, lo, hi = _kde_sets(cols, p, scale)
433
+ if span is not None:
434
+ lo, hi = span
435
+ n = p["points"]
436
+ nan = array.array("d", [math.nan]) * n
437
+ dens = [kernels.kde(fv, lo, hi, n, bw, w, p["cumulative"])
438
+ if math.isfinite(bw) else array.array("d", nan)
439
+ for fv, bw, w in zip(fsets, bws, ws)]
440
+ missing = sum(1 for bw in bws if not math.isfinite(bw))
441
+ if missing:
442
+ what = "set has" if missing == 1 else "sets have"
443
+ warnings.warn(f"kde: {missing} {what} no density (no bandwidth: fewer than 2 "
444
+ f"distinct values, a weight total of at most 1, or a range outside "
445
+ f"the data)", stacklevel=3)
446
+ grid = array.array("d", (scale.inverse(lo + (hi - lo) * i / (n - 1)) for i in range(n)))
447
+ return {"grid": grid, "densities": dens, "bandwidths": bws}
448
+
449
+
450
+ def _ecdf_peers(series: Any, scales: tuple[Any, ...]) -> tuple[float, ...] | None:
451
+ """The values stacked ecdf calls of the plot share: every finite value of theirs."""
452
+ members = _stacked_peers(series)
453
+ if not members:
454
+ return None
455
+ grid: set[float] = set()
456
+ for k in members:
457
+ for _, v in k.cols.sets:
458
+ grid.update(x for x in doubles(v) if math.isfinite(x))
459
+ return tuple(sorted(grid))
460
+
461
+
462
+ @stat("ecdf", reads=("weights", "stat", "complementary"),
463
+ defaults={"weights": None, "stat": "proportion", "complementary": False},
464
+ peers=_ecdf_peers)
465
+ def ecdf(cols: Any, p: Params, scales: tuple[Any, ...] = (),
466
+ grid: tuple[float, ...] | None = None) -> list[tuple[array.array, array.array, float]]:
467
+ """Per set: sorted values, the cumulative values plotted, and the value before the
468
+ first step (0, or the total for complementary curves); ``grid``: the values of the
469
+ stacked calls, each set's steps read at them."""
470
+ import bisect
471
+ weights = p["weights"]
472
+ st = p["stat"]
473
+ out = []
474
+ for i, (key, v) in enumerate(cols.sets):
475
+ xs, cs = kernels.ecdf(v, _weight(weights, key, i))
476
+ total = cs[-1] if len(cs) else 0.0
477
+ k = 1.0 if st == "count" else (100.0 if st == "percent" else 1.0) / (total or 1.0)
478
+ top = total * k
479
+ ys = array.array("d", (c * k for c in cs))
480
+ start = 0.0
481
+ if p["complementary"]:
482
+ ys = array.array("d", (top - y for y in ys))
483
+ start = top
484
+ if grid is not None:
485
+ at = [bisect.bisect_right(xs, g) - 1 for g in grid]
486
+ ys = array.array("d", (ys[j] if j >= 0 else start for j in at))
487
+ xs = array.array("d", grid)
488
+ out.append((xs, ys, start))
489
+ return out
490
+
491
+
492
+ @stat("box", reads=("whiskers", "weights"), defaults={"whiskers": 1.5, "weights": None})
493
+ def box(cols: Any, p: Params, scales: tuple[Any, ...] = ()) -> dict[Any, Any]:
494
+ """``{category: BoxStats}``: count, mean, q1, median, q3, low, high, outliers
495
+ (``weights``: as repeated values)."""
496
+ w = p["whiskers"]
497
+ ws = _category_weights(cols, p.get("weights"))
498
+ return {k: kernels.box_stats(v, w, wk)
499
+ for k, v, wk in zip(cols.categories, cols.values, ws)}
500
+
501
+
502
+ @stat("violin", reads=("bandwidth", "adjust", "cut", "points", "orientation", "weights"),
503
+ defaults={"bandwidth": "scott", "adjust": 1.0, "cut": 2.0, "points": 512,
504
+ "orientation": "vertical", "weights": None},
505
+ scales=_y_scale)
506
+ def violin(cols: Any, p: Params, scales: tuple[Any, ...]) -> dict[Any, dict[str, Any]]:
507
+ """``{category: {"grid", "density", "count", "box": BoxStats, "bandwidth", "line"}}``:
508
+ densities in the value axis' space (``scale``), per its unit. A category without a
509
+ bandwidth (one value, or all equal) is a line at its value (``"line"``: true), with a
510
+ warning."""
511
+ scale = scales[0]
512
+ out = {}
513
+ n = p["points"]
514
+ lines = 0
515
+ ws = _category_weights(cols, p.get("weights"))
516
+ for k, d, w in zip(cols.categories, cols.values, ws):
517
+ bs = kernels.box_stats(d, 1.5, w)
518
+ d, w = _kept(d, w)
519
+ fd = _f(d, scale)
520
+ cnt: float
521
+ cnt, lo, hi = kernels.extent(fd, None)
522
+ if cnt and w is not None:
523
+ cnt = _weighted_count(fd, w)
524
+ if not cnt:
525
+ out[k] = {"grid": array.array("d"), "density": array.array("d"), "count": 0,
526
+ "box": bs, "bandwidth": 0.0, "line": False}
527
+ continue
528
+ bw = _bandwidth(fd, p["bandwidth"], w) * p["adjust"]
529
+ if not (math.isfinite(bw) and bw > 0) or lo == hi:
530
+ lines += 1
531
+ v = scale.inverse(lo)
532
+ out[k] = {"grid": array.array("d", [v, v]), "density": array.array("d", [1.0, 1.0]),
533
+ "count": cnt, "box": bs, "bandwidth": math.nan, "line": True}
534
+ continue
535
+ cut = p["cut"]
536
+ g0, g1 = lo - cut * bw, hi + cut * bw
537
+ if not (math.isfinite(g0) and math.isfinite(g1)):
538
+ raise ValueError(f"violin: a bandwidth of {bw:g} (bandwidth × adjust settings) "
539
+ "reaches past the largest number")
540
+ dens = kernels.kde(fd, g0, g1, n, bw, w)
541
+ grid = array.array("d", (scale.inverse(g0 + (g1 - g0) * i / (n - 1))
542
+ for i in range(n)))
543
+ out[k] = {"grid": grid, "density": dens, "count": cnt, "box": bs, "bandwidth": bw,
544
+ "line": False}
545
+ if lines:
546
+ what = "violin is" if lines == 1 else "violins are"
547
+ warnings.warn(f"violin: {lines} {what} drawn as a line (no bandwidth: one value, "
548
+ "all equal, or a weight total of at most 1)", stacklevel=3)
549
+ return out
550
+
551
+
552
+ def density_at(s: dict[str, Any], v: float, scale: Any = None) -> float:
553
+ """A violin's density at value ``v`` (linear between grid points in the space of
554
+ ``scale``)."""
555
+ g, d = s["grid"], s["density"]
556
+ n = len(g)
557
+ f = (lambda x: x) if scale is None else scale.f
558
+ a, b, t = f(g[0]) if n else 0.0, f(g[-1]) if n else 0.0, f(v)
559
+ if not n or not (a <= t <= b):
560
+ return 0.0
561
+ u = (t - a) / (b - a) * (n - 1) if b > a else 0.0
562
+ i = min(int(u), n - 2)
563
+ if i < 0:
564
+ return d[0]
565
+ r = u - i
566
+ return d[i] * (1 - r) + d[i + 1] * r
567
+
568
+
569
+ def lowess(x: Any, y: Any, frac: float = 2.0 / 3.0,
570
+ iterations: int = 3) -> tuple[array.array, array.array]:
571
+ """Locally weighted linear regression: the sorted x values (pairs with a NaN left out)
572
+ and the fitted y values."""
573
+ pairs = sorted((a, b) for a, b in zip(doubles(x), doubles(y))
574
+ if math.isfinite(a) and math.isfinite(b))
575
+ xs = array.array("d", (p[0] for p in pairs))
576
+ ys = array.array("d", (p[1] for p in pairs))
577
+ if len(xs) < 2:
578
+ return xs, ys
579
+ return xs, kernels.lowess(xs, ys, frac, iterations)
580
+
581
+
582
+ # ---------------------------------------------------------------------------
583
+ # Regression
584
+ # ---------------------------------------------------------------------------
585
+
586
+ def _solve(a: list[list[float]], b: list[float]) -> list[float]:
587
+ """Gaussian elimination with partial pivoting."""
588
+ n = len(b)
589
+ m = [row[:] + [bv] for row, bv in zip(a, b)]
590
+ for i in range(n):
591
+ p = max(range(i, n), key=lambda r: abs(m[r][i]))
592
+ if abs(m[p][i]) < 1e-300:
593
+ raise ValueError("the fit is singular (too few distinct x values for the order)")
594
+ m[i], m[p] = m[p], m[i]
595
+ for r in range(i + 1, n):
596
+ f = m[r][i] / m[i][i]
597
+ for c in range(i, n + 1):
598
+ m[r][c] -= f * m[i][c]
599
+ x = [0.0] * n
600
+ for i in range(n - 1, -1, -1):
601
+ x[i] = (m[i][n] - sum(m[i][c] * x[c] for c in range(i + 1, n))) / m[i][i]
602
+ return x
603
+
604
+
605
+ def _inverse(a: list[list[float]]) -> list[list[float]]:
606
+ n = len(a)
607
+ cols = [_solve(a, [1.0 if r == c else 0.0 for r in range(n)]) for c in range(n)]
608
+ return [[cols[c][r] for c in range(n)] for r in range(n)]
609
+
610
+
611
+ def _betacf(a: float, b: float, x: float) -> float:
612
+ qab, qap, qam = a + b, a + 1.0, a - 1.0
613
+ c, d = 1.0, 1.0 - qab * x / qap
614
+ d = 1.0 / (d if abs(d) > 1e-300 else 1e-300)
615
+ h = d
616
+ for m in range(1, 300):
617
+ m2 = 2 * m
618
+ aa = m * (b - m) * x / ((qam + m2) * (a + m2))
619
+ d = 1.0 + aa * d
620
+ d = 1.0 / (d if abs(d) > 1e-300 else 1e-300)
621
+ c = 1.0 + aa / c if abs(c) > 1e-300 else 1e300
622
+ h *= d * c
623
+ aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
624
+ d = 1.0 + aa * d
625
+ d = 1.0 / (d if abs(d) > 1e-300 else 1e-300)
626
+ c = 1.0 + aa / c if abs(c) > 1e-300 else 1e300
627
+ de = d * c
628
+ h *= de
629
+ if abs(de - 1.0) < 1e-15:
630
+ break
631
+ return h
632
+
633
+
634
+ def _betai(a: float, b: float, x: float) -> float:
635
+ if x <= 0:
636
+ return 0.0
637
+ if x >= 1:
638
+ return 1.0
639
+ lb = math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b) + a * math.log(x) \
640
+ + b * math.log(1 - x)
641
+ if x < (a + 1) / (a + b + 2):
642
+ return math.exp(lb) * _betacf(a, b, x) / a
643
+ return 1.0 - math.exp(lb) * _betacf(b, a, 1 - x) / b
644
+
645
+
646
+ def _t_cdf(t: float, df: float) -> float:
647
+ x = df / (df + t * t)
648
+ p = 0.5 * _betai(df / 2.0, 0.5, x)
649
+ return 1.0 - p if t > 0 else p
650
+
651
+
652
+ def t_quantile(p: float, df: float) -> float:
653
+ """The ``p`` quantile of Student's t distribution with ``df`` degrees of freedom."""
654
+ if df <= 0:
655
+ return math.inf
656
+ lo, hi = -1e3, 1e3
657
+ for _ in range(200):
658
+ mid = (lo + hi) / 2
659
+ if _t_cdf(mid, df) < p:
660
+ lo = mid
661
+ else:
662
+ hi = mid
663
+ return (lo + hi) / 2
664
+
665
+
666
+ # Wichura's AS241 (PPND16): relative error about 1e-16
667
+ _NQ_A = (3.387132872796366608, 133.14166789178437745, 1971.5909503065514427,
668
+ 13731.693765509461125, 45921.953931549871457, 67265.770927008700853,
669
+ 33430.575583588128105, 2509.0809287301226727)
670
+ _NQ_B = (1.0, 42.313330701600911252, 687.1870074920579083, 5394.1960214247511077,
671
+ 21213.794301586595867, 39307.89580009271061, 28729.085735721942674,
672
+ 5226.495278852545925)
673
+ _NQ_C = (1.42343711074968357734, 4.6303378461565452959, 5.7694972214606914055,
674
+ 3.64784832476320460504, 1.27045825245236838258, 0.24178072517745061177,
675
+ 0.0227238449892691845833, 7.7454501427834140764e-4)
676
+ _NQ_D = (1.0, 2.05319162663775882187, 1.6763848301838038494, 0.68976733498510000455,
677
+ 0.14810397642748007459, 0.0151986665636164571966, 5.475938084995344946e-4,
678
+ 1.05075007164441684324e-9)
679
+ _NQ_E = (6.6579046435011037772, 5.4637849111641143699, 1.7848265399172913358,
680
+ 0.29656057182850489123, 0.026532189526576123093, 0.0012426609473880784386,
681
+ 2.71155556874348757815e-5, 2.01033439929228813265e-7)
682
+ _NQ_F = (1.0, 0.59983220655588793769, 0.13692988092273580531, 0.0148753612908506148525,
683
+ 7.868691311456132591e-4, 1.8463183175100546818e-5, 1.4215117583164458887e-7,
684
+ 2.04426310338993978564e-15)
685
+
686
+
687
+ def _poly(c: tuple[float, ...], r: float) -> float:
688
+ v = c[7]
689
+ for k in range(6, -1, -1):
690
+ v = v * r + c[k]
691
+ return v
692
+
693
+
694
+ def norm_quantile(p: float) -> float:
695
+ """The ``p`` quantile of the standard normal distribution (Wichura's AS241); ±inf at 0
696
+ and 1, NaN outside [0, 1]."""
697
+ if not 0.0 < p < 1.0:
698
+ return -math.inf if p == 0.0 else math.inf if p == 1.0 else math.nan
699
+ q = p - 0.5
700
+ if abs(q) <= 0.425:
701
+ r = 0.180625 - q * q
702
+ return q * _poly(_NQ_A, r) / _poly(_NQ_B, r)
703
+ r = math.sqrt(-math.log(p if q < 0 else 1.0 - p))
704
+ if r <= 5.0:
705
+ v = _poly(_NQ_C, r - 1.6) / _poly(_NQ_D, r - 1.6)
706
+ else:
707
+ v = _poly(_NQ_E, r - 5.0) / _poly(_NQ_F, r - 5.0)
708
+ return -v if q < 0 else v
709
+
710
+
711
+ def beta_quantile(p: float, a: float, b: float) -> float:
712
+ """The ``p`` quantile of the beta distribution with shapes ``a`` and ``b``: the
713
+ regularized incomplete beta function inverted by Newton steps kept in a bisection
714
+ bracket (relative error about 1e-12)."""
715
+ if not 0.0 < p < 1.0:
716
+ return 0.0 if p <= 0.0 else 1.0
717
+ lbeta = math.lgamma(a) + math.lgamma(b) - math.lgamma(a + b)
718
+ s = a + b
719
+ # start: a normal approximation, or the tails' x^a / (a B) and 1 - (1-x)^b / (b B)
720
+ x = a / s + norm_quantile(p) * math.sqrt(a * b / (s * s * (s + 1)))
721
+ if not 0.0 < x < 1.0 or min(a, b) < 10:
722
+ x = math.exp((math.log(p * a) + lbeta) / a) if p < 0.5 \
723
+ else -math.expm1((math.log((1 - p) * b) + lbeta) / b)
724
+ if not 0.0 < x < 1.0:
725
+ x = a / s
726
+ lo, hi = 0.0, 1.0
727
+ for _ in range(200):
728
+ f = _betai(a, b, x) - p
729
+ if f < 0:
730
+ lo = x
731
+ else:
732
+ hi = x
733
+ d = math.exp((a - 1) * math.log(x) + (b - 1) * math.log1p(-x) - lbeta)
734
+ nx = x - f / d if d > 0 else math.nan
735
+ if not lo < nx < hi:
736
+ nx = (lo + hi) / 2
737
+ if abs(nx - x) <= 1e-12 * min(x, 1.0 - x) or hi - lo <= 4e-16 * hi:
738
+ return nx
739
+ x = nx
740
+ return x
741
+
742
+
743
+ @stat("regression", reads=("logx", "points", "range", "lowess", "frac", "order", "ci",
744
+ "weights"),
745
+ defaults={"logx": False, "points": 100, "range": None, "lowess": False,
746
+ "frac": 2.0 / 3.0, "order": 1, "ci": 95.0, "weights": None})
747
+ def regression(cols: Any, p: Params, scales: tuple[Any, ...] = ()) -> dict[str, Any]:
748
+ """``coefficients`` (lowest power first, in x or log x), ``r2``, ``se`` (residual
749
+ standard error), and the curve: ``x``, ``y``, ``low``, ``high``. ``weights`` count as
750
+ repeated points (weighted least squares, ``sum w - k`` degrees of freedom)."""
751
+ logx = p["logx"]
752
+ weights = p.get("weights")
753
+ if weights is not None and p["lowess"]:
754
+ raise ValueError(f"{p.kind}: lowess=True does not take weights")
755
+ wv = kernels._weights(weights, len(cols["x"])) if weights is not None else None
756
+ pts = [(math.log(a) if logx else a, b) for a, b in zip(cols["x"], cols["y"])
757
+ if math.isfinite(a) and math.isfinite(b) and (a > 0 or not logx)]
758
+ ws = [1.0] * len(pts) if wv is None else \
759
+ [c for a, b, c in zip(cols["x"], cols["y"], wv)
760
+ if math.isfinite(a) and math.isfinite(b) and (a > 0 or not logx)]
761
+ if wv is not None:
762
+ pts = [q for q, c in zip(pts, ws) if c > 0]
763
+ ws = [c for c in ws if c > 0]
764
+ n = len(pts)
765
+ npts = p["points"]
766
+ if n < 2:
767
+ return {"coefficients": [], "r2": math.nan, "se": math.nan, "x": array.array("d"),
768
+ "y": array.array("d"), "low": None, "high": None}
769
+ distinct = len({p[0] for p in pts})
770
+ if distinct < 2:
771
+ warnings.warn("regression: x has one distinct value: no fit", stacklevel=3)
772
+ return {"coefficients": [], "r2": math.nan, "se": math.nan, "x": array.array("d"),
773
+ "y": array.array("d"), "low": None, "high": None}
774
+ lo = min(p[0] for p in pts)
775
+ hi = max(p[0] for p in pts)
776
+ rng = p["range"]
777
+ if rng is not None:
778
+ lo, hi = ((math.log(v) if v > 0 else math.nan) if logx else v for v in rng)
779
+ if not (math.isfinite(lo) and math.isfinite(hi)):
780
+ raise ValueError(f"range {tuple(rng)} must be positive with logx")
781
+ grid = [lo + (hi - lo) * i / (npts - 1) for i in range(npts)]
782
+ if p["lowess"]:
783
+ xs, fit = lowess([p[0] for p in pts], [p[1] for p in pts], p["frac"])
784
+ gx = array.array("d", (math.exp(v) for v in xs) if logx else xs)
785
+ return {"coefficients": [], "r2": math.nan, "se": math.nan, "x": gx, "y": fit,
786
+ "low": None, "high": None}
787
+ order = p["order"]
788
+ if order > distinct - 1:
789
+ warnings.warn(f"regression: order {order} lowered to {distinct - 1} ({distinct} "
790
+ "distinct x values)", stacklevel=3)
791
+ k = min(order, distinct - 1) + 1
792
+ sw = math.fsum(ws)
793
+ mx = math.fsum(c * p[0] for p, c in zip(pts, ws)) / sw
794
+ d = max(abs(p[0] - mx) for p in pts) # scaled before squaring
795
+ sx = d * math.sqrt(math.fsum(c * ((p[0] - mx) / d) ** 2 for p, c in zip(pts, ws))
796
+ / sw) or 1.0
797
+
798
+ def row(v: float) -> list[float]:
799
+ u = (v - mx) / sx
800
+ return [u ** j for j in range(k)]
801
+
802
+ rows = [row(p[0]) for p in pts]
803
+ ata = [[math.fsum(c * r[i] * r[j] for r, c in zip(rows, ws)) for j in range(k)]
804
+ for i in range(k)]
805
+ aty = [math.fsum(c * r[i] * p[1] for r, p, c in zip(rows, pts, ws)) for i in range(k)]
806
+ beta = _solve(ata, aty)
807
+ fitted = [sum(b * v for b, v in zip(beta, r)) for r in rows]
808
+ my = math.fsum(c * p[1] for p, c in zip(pts, ws)) / sw
809
+ ss_res = math.fsum(c * (p[1] - f) ** 2 for p, f, c in zip(pts, fitted, ws))
810
+ ss_tot = math.fsum(c * (p[1] - my) ** 2 for p, c in zip(pts, ws))
811
+ dof = sw - k
812
+ se = math.sqrt(ss_res / dof) if dof > 0 else math.nan
813
+ gy = [sum(b * v for b, v in zip(beta, row(g))) for g in grid]
814
+ low = high = None
815
+ ci = p["ci"]
816
+ if ci is not None and dof > 0:
817
+ inv = _inverse(ata)
818
+ t = t_quantile(0.5 + ci / 200.0, dof)
819
+ low, high = array.array("d"), array.array("d")
820
+ for g, yv in zip(grid, gy):
821
+ r = row(g)
822
+ var = sum(r[i] * inv[i][j] * r[j] for i in range(k) for j in range(k))
823
+ h = t * se * math.sqrt(max(var, 0.0))
824
+ low.append(yv - h)
825
+ high.append(yv + h)
826
+ coef = [0.0] * k # coefficients in powers of x (or log x)
827
+ for j, b in enumerate(beta):
828
+ for i in range(j + 1):
829
+ try:
830
+ scale = sx ** -i
831
+ except OverflowError:
832
+ scale = math.inf
833
+ coef[i] += b * math.comb(j, i) * (-mx / sx) ** (j - i) * scale
834
+ gx = array.array("d", (math.exp(v) for v in grid) if logx else grid)
835
+ return {"coefficients": coef, "r2": 1.0 - ss_res / ss_tot if ss_tot else math.nan,
836
+ "se": se, "x": gx, "y": array.array("d", gy), "low": low, "high": high}
837
+
838
+
839
+ @typed("Literal['mean', 'median'] | Callable[..., Any]")
840
+ def check_estimate(v: Any) -> Any:
841
+ """An ``estimate`` setting: ``"mean"``, ``"median"``, a function of an array or
842
+ ``None``."""
843
+ if v is None or (isinstance(v, str) and v in ("mean", "median")):
844
+ return v
845
+ if callable(v):
846
+ import inspect
847
+ try:
848
+ inspect.signature(v).bind(None)
849
+ except TypeError:
850
+ raise ValueError(f"estimate: {v!r} does not take one argument (the values, an "
851
+ "array)") from None
852
+ except ValueError:
853
+ pass # no signature to check (a builtin)
854
+ return v
855
+ raise ValueError(f"estimate is 'mean', 'median' or a function of an array, not {v!r}")
856
+
857
+
858
+ @typed("Literal['ci', 'se', 'sd', 'pi'] | tuple[Literal['ci', 'pi'], SupportsFloat] | "
859
+ "list[Any]")
860
+ def check_interval(v: Any) -> Any:
861
+ """An ``interval`` setting: ``"ci"``, ``"se"``, ``"sd"``, ``"pi"``, ``("ci", level)``,
862
+ ``("pi", level)`` (0 < level < 1) or ``None``."""
863
+ if v is None or (isinstance(v, str) and v in ("ci", "se", "sd", "pi")):
864
+ return v
865
+ if isinstance(v, (tuple, list)) and len(v) == 2 and isinstance(v[0], str) and v[0] in ("ci", "pi"):
866
+ level = real(v[1])
867
+ if level is not None and 0 < level < 1:
868
+ return (v[0], level)
869
+ raise ValueError("interval is 'ci', 'se', 'sd', 'pi', ('ci', level) or ('pi', level) "
870
+ f"with 0 < level < 1, not {v!r}")
871
+
872
+
873
+ def interval_of(v: Any) -> tuple[str, float] | None:
874
+ """An ``interval`` setting as ``(name, level)``."""
875
+ if v is None:
876
+ return None
877
+ name, level = (v, 0.95) if isinstance(v, str) else v
878
+ return name, (1.0 if name in ("se", "sd") else level)
879
+
880
+
881
+ @stat("summary", reads=("estimate", "interval"), defaults={"estimate": None, "interval": None})
882
+ def summary(cols: Any, p: Params, scales: tuple[Any, ...] = ()) -> dict[str, Any]:
883
+ """Replicates summarized per position and set: ``x`` (the positions: numbers sorted,
884
+ categories in their order), ``sets``: ``(set, estimate, low, high)`` (the interval's
885
+ extent below and above the estimate; NaN with fewer than 2 values for ``ci``, ``se``,
886
+ ``sd``), ``keys``: the categories (else ``None``), ``named``."""
887
+ kinds = cols.kinds
888
+ name = next(n for n in kinds if n != "x")
889
+ keys = cols.keys.get("x")
890
+ if kinds[name] == "heights":
891
+ pos, sets = cols.categories, [(0, cols[name])]
892
+ elif kinds[name] == "sets":
893
+ pos, sets = cols["x"], cols.sets
894
+ else:
895
+ if name in cols.keys:
896
+ raise ValueError(f"{p.kind}: estimate needs numbers in {name}, not categories")
897
+ pos, sets = cols["x"], [(0, cols[name])]
898
+ rows: dict[Any, list[int]] = {}
899
+ for i, k in enumerate(pos):
900
+ if k is not None and k == k:
901
+ rows.setdefault(k, []).append(i)
902
+ if keys is not None and kinds[name] == "heights":
903
+ xs = [k for k in keys.levels if k in rows]
904
+ else:
905
+ xs = sorted(rows)
906
+ est, iv = p["estimate"], interval_of(p["interval"])
907
+ ts: dict[int, float] = {}
908
+ out = []
909
+ for k, column in sets:
910
+ e, lo, hi = (array.array("d") for _ in range(3))
911
+ for x in xs:
912
+ a = [v for v in (column[i] for i in rows[x]) if v == v]
913
+ m, below, above = _summary(a, est, iv, ts)
914
+ e.append(m)
915
+ lo.append(below)
916
+ hi.append(above)
917
+ out.append((k, e, lo, hi))
918
+ if keys is not None and kinds[name] != "heights":
919
+ xs = [keys.levels[int(i)] for i in xs]
920
+ return {"x": xs, "sets": out, "keys": keys, "named": cols.named}
921
+
922
+
923
+ def _summary(a: list[float], est: Any, iv: tuple[str, float] | None,
924
+ ts: dict[int, float]) -> tuple[float, float, float]:
925
+ """``(estimate, below, above)`` of the values ``a``."""
926
+ n = len(a)
927
+ if not n:
928
+ return math.nan, math.nan, math.nan
929
+ mean = math.fsum(a) / n
930
+ if est == "mean":
931
+ m = mean
932
+ elif est == "median":
933
+ m = kernels.quantiles(a, [0.5])[0]
934
+ else:
935
+ r = est(array.array("d", a))
936
+ try:
937
+ m = float(r)
938
+ except (TypeError, ValueError):
939
+ raise ValueError(f"estimate: the function gave {r!r}, not a number") from None
940
+ if iv is None:
941
+ return m, math.nan, math.nan
942
+ name, level = iv
943
+ if name == "pi":
944
+ lo, hi = kernels.quantiles(a, [(1 - level) / 2, (1 + level) / 2])
945
+ return m, m - lo, hi - m
946
+ sd = math.sqrt(math.fsum((v - mean) ** 2 for v in a) / (n - 1)) if n > 1 else math.nan
947
+ if name == "sd":
948
+ return m, sd, sd
949
+ h = sd / math.sqrt(n) if n > 1 else math.nan
950
+ if name == "ci" and n > 1:
951
+ if n - 1 not in ts:
952
+ ts[n - 1] = t_quantile((1 + level) / 2, n - 1)
953
+ h *= ts[n - 1]
954
+ return m, h, h
955
+
956
+
957
+ # ---------------------------------------------------------------------------
958
+ # 2-D
959
+ # ---------------------------------------------------------------------------
960
+
961
+ @stat("bin2d", reads=("bins", "range", "weights", "stat", "min_count"),
962
+ defaults={"bins": ("auto", "auto"), "range": None, "weights": None, "stat": "count",
963
+ "min_count": 1.0})
964
+ def bin2d(cols: Any, p: Params, scales: tuple[Any, ...]) -> dict[str, Any]:
965
+ """``{"x_edges", "y_edges", "counts": rows (first y bin first), "values": rows}``: bins
966
+ even on the axis scales ``sx``, ``sy``; bins under ``min_count`` are NaN."""
967
+ sx, sy = scales
968
+ bx, by = p["bins"]
969
+ rng = p["range"]
970
+ fx, fy = _f(cols["x"], sx), _f(cols["y"], sy)
971
+ w = p["weights"]
972
+
973
+ def edges(b: Any, v: Any, sc: Any, r: Any, axis: str) -> Any:
974
+ if isinstance(b, array.array):
975
+ return _edges_in(p, b, sc, axis)
976
+ return kernels.bin_edges(v, b, _range(p, r, sc, axis))
977
+
978
+ xe = edges(bx, fx, sx, None if rng is None else rng[0], "x")
979
+ ye = edges(by, fy, sy, None if rng is None else rng[1], "y")
980
+ nr, nc, flat = kernels.histogram2d(fx, fy, xe, ye, w)
981
+ counts = [flat[r * nc:(r + 1) * nc] for r in range(nr)]
982
+ total = math.fsum(math.fsum(r) for r in counts)
983
+ st = p["stat"]
984
+ xd = [sx.inverse(e) for e in xe]
985
+ yd = [sy.inverse(e) for e in ye]
986
+ mc = p["min_count"]
987
+ rows = []
988
+ for j, r in enumerate(counts):
989
+ out = array.array("d")
990
+ for i, c in enumerate(r):
991
+ if c < mc or c == 0:
992
+ out.append(math.nan)
993
+ elif st == "count":
994
+ out.append(c)
995
+ elif st == "probability":
996
+ out.append(c / total)
997
+ elif st == "percent":
998
+ out.append(100.0 * c / total)
999
+ else:
1000
+ area = (xe[i + 1] - xe[i]) * (ye[j + 1] - ye[j])
1001
+ out.append(c / (total * area) if area else math.nan)
1002
+ rows.append(out)
1003
+ return {"x_edges": array.array("d", xd), "y_edges": array.array("d", yd),
1004
+ "counts": counts, "values": rows}
1005
+
1006
+
1007
+ _S3 = math.sqrt(3.0)
1008
+
1009
+
1010
+ @stat("hexbin", reads=("bins", "weights", "stat", "min_count"),
1011
+ defaults={"bins": 30, "weights": None, "stat": "count", "min_count": 1.0})
1012
+ def hexbin(cols: Any, p: Params, scales: tuple[Any, ...], frame: tuple) -> dict[str, Any]:
1013
+ """``{"x", "y": hexagon centres (page units, relative to the axes' top-left corner),
1014
+ "values", "dx": hexagon width (px)}``: a lattice of ``bins`` hexagons across the
1015
+ axes laid out in the page ``frame`` ``(function_x, a_x, b_x, function_y, a_y, b_y,
1016
+ width, height)``."""
1017
+ fx, ax, bx, fy, ay, by, w, h = frame
1018
+ nx = p["bins"]
1019
+ dx = w / nx
1020
+ dy = _S3 * dx
1021
+ nan = math.nan
1022
+ u = kernels.scaled(cols["x"], ax, bx, fx)
1023
+ v = kernels.scaled(cols["y"], ay, by, fy)
1024
+ # Points outside the axes are not counted.
1025
+ u = kernels.scaled(u, 1.0, 0.0, None, low=0.0, high=w, under=nan, over=nan)
1026
+ v = kernels.scaled(v, 1.0, 0.0, None, low=0.0, high=h, under=nan, over=nan)
1027
+ ny = int(h / dy) + 2
1028
+ cx, cy, cs = kernels.hexbin(u, v, 0.0, 0.0, dx, dy, nx + 1, ny, p["weights"])
1029
+ total = math.fsum(cs)
1030
+ mc = p["min_count"]
1031
+ keep = [i for i in range(len(cs)) if cs[i] >= mc and cs[i] > 0]
1032
+ st = p["stat"]
1033
+ k = 1.0 if st == "count" else ((100.0 if st == "percent" else 1.0) / (total or 1.0))
1034
+ return {"x": array.array("d", (cx[i] for i in keep)),
1035
+ "y": array.array("d", (cy[i] for i in keep)),
1036
+ "values": array.array("d", (cs[i] * k for i in keep)), "dx": dx}
1037
+
1038
+
1039
+ @stat("kde2d", reads=("bandwidth", "adjust", "cut", "points", "weights"),
1040
+ defaults={"bandwidth": "scott", "adjust": 1.0, "cut": 3.0, "points": 128,
1041
+ "weights": None})
1042
+ def kde2d(cols: Any, p: Params, scales: tuple[Any, ...]) -> dict[str, Any]:
1043
+ """``{"fx", "fy": grid nodes after the axis scale functions, "flat", "w", "h"}``: the 2-D
1044
+ Gaussian density of ``(x, y)`` over the data plus ``cut`` bandwidths (``weights``: as
1045
+ repeated points)."""
1046
+ sx, sy = scales
1047
+ from ..density import Density
1048
+ x, y, w = cols["x"], cols["y"], p.get("weights")
1049
+ if w is not None:
1050
+ if len(w) != len(x):
1051
+ raise ValueError(f"{len(w)} weights for {len(x)} points")
1052
+ y, _ = _kept(y, w)
1053
+ x, w = _kept(x, w)
1054
+ fx, fy = _f(x, sx), _f(y, sy)
1055
+ d = Density(p["bandwidth"], p["adjust"])
1056
+ h = d.covariance(fx, fy, w)
1057
+ n = int(p["points"])
1058
+ _, x0, x1 = kernels.extent(fx, None)
1059
+ _, y0, y1 = kernels.extent(fy, None)
1060
+ if not all(math.isfinite(v) for v in (x0, x1, y0, y1)) or \
1061
+ not all(math.isfinite(v) for v in h):
1062
+ return {"fx": array.array("d", [0.0, 1.0]), "fy": array.array("d", [0.0, 1.0]),
1063
+ "flat": array.array("d", [0.0] * 4), "w": 2, "h": 2}
1064
+ cut = p["cut"]
1065
+ x0, x1 = x0 - cut * math.sqrt(h[0]), x1 + cut * math.sqrt(h[0])
1066
+ y0, y1 = y0 - cut * math.sqrt(h[2]), y1 + cut * math.sqrt(h[2])
1067
+ flat = kernels.kde2d_grid(fx, fy, h, x0, x1, n, y0, y1, n, w)
1068
+ gx = array.array("d", (x0 + (x1 - x0) * i / (n - 1) for i in range(n)))
1069
+ gy = array.array("d", (y0 + (y1 - y0) * j / (n - 1) for j in range(n)))
1070
+ return {"fx": gx, "fy": gy, "flat": flat, "w": n, "h": n}
1071
+
1072
+
1073
+ def contour(flat: Any, w: int, h: int, fx: Any, fy: Any, levels: list[float], lines: bool,
1074
+ bands: list[tuple[float, float, float]]) -> dict[str, Any]:
1075
+ """``{"levels", "lines": {level: [(x, y, closed)]}, "bands": [(lo, hi, (xs, ys,
1076
+ ring_starts))]}`` of a grid ``flat`` (``h`` rows of ``w``) at nodes ``fx``/``fy`` (after
1077
+ the axis scale functions); ``bands``: ``(lo, hi, top)``, the band's limits and the top
1078
+ used to cut it."""
1079
+ out_lines: dict[float, Any] = {}
1080
+ if lines:
1081
+ for level in levels:
1082
+ out_lines[level] = kernels.contour_lines(flat, w, h, fx, fy, level)
1083
+ out_bands = []
1084
+ for lo, hi, top in bands:
1085
+ rings = kernels.contour_bands(flat, w, h, fx, fy, lo, top)
1086
+ if len(rings[0]):
1087
+ out_bands.append((lo, hi, rings))
1088
+ return {"levels": levels, "lines": out_lines, "bands": out_bands, "f_space": True}