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.
- plot_io/__init__.py +73 -0
- plot_io/_methods.py +1691 -0
- plot_io/_native/__init__.py +199 -0
- plot_io/_native/__main__.py +197 -0
- plot_io/_native/_api.py +457 -0
- plot_io/_native/bin/windows-x86_64.dll +0 -0
- plot_io/_native/kernels.py +335 -0
- plot_io/_native/manifest.json +49 -0
- plot_io/axis.py +1347 -0
- plot_io/boxes.py +1156 -0
- plot_io/canvas.py +389 -0
- plot_io/color/__init__.py +3 -0
- plot_io/color/data.py +1646 -0
- plot_io/color/maps.py +922 -0
- plot_io/color/values.py +295 -0
- plot_io/color/variants.py +1246 -0
- plot_io/colorbar.py +1426 -0
- plot_io/columns.py +614 -0
- plot_io/decorations.py +920 -0
- plot_io/defaults.py +809 -0
- plot_io/density.py +113 -0
- plot_io/ext.py +68 -0
- plot_io/facets.py +240 -0
- plot_io/figure.py +788 -0
- plot_io/formatters.py +97 -0
- plot_io/genome/__init__.py +17 -0
- plot_io/genome/adapters.py +356 -0
- plot_io/genome/combine.py +123 -0
- plot_io/genome/contacts.py +508 -0
- plot_io/genome/intervals.py +750 -0
- plot_io/genome/lanes.py +191 -0
- plot_io/genome/layout.py +184 -0
- plot_io/genome/links.py +364 -0
- plot_io/genome/pack.py +27 -0
- plot_io/genome/readers.py +145 -0
- plot_io/genome/regions.py +228 -0
- plot_io/genome/scale.py +349 -0
- plot_io/genome/series.py +344 -0
- plot_io/genome/signal.py +621 -0
- plot_io/genome/sites.py +782 -0
- plot_io/genome/sources.py +459 -0
- plot_io/genome/testing.py +161 -0
- plot_io/geometry.py +289 -0
- plot_io/groups.py +308 -0
- plot_io/guides.py +503 -0
- plot_io/helpers/__init__.py +17 -0
- plot_io/helpers/clustermap.py +202 -0
- plot_io/helpers/dendrogram.py +172 -0
- plot_io/helpers/grids.py +304 -0
- plot_io/helpers/qq.py +334 -0
- plot_io/helpers/raincloud.py +249 -0
- plot_io/helpers/tree.py +214 -0
- plot_io/helpers/volcano.py +505 -0
- plot_io/hints.py +125 -0
- plot_io/kernels.py +740 -0
- plot_io/labels.py +566 -0
- plot_io/layers/__init__.py +100 -0
- plot_io/layers/base.py +630 -0
- plot_io/layers/context.py +87 -0
- plot_io/layers/core.py +446 -0
- plot_io/layers/geoms.py +1362 -0
- plot_io/layers/grammar.py +586 -0
- plot_io/layers/paint.py +614 -0
- plot_io/layers/pipeline.py +170 -0
- plot_io/layers/positions.py +431 -0
- plot_io/layers/stats.py +1088 -0
- plot_io/layout.py +1381 -0
- plot_io/legend.py +605 -0
- plot_io/levels.py +509 -0
- plot_io/limits.py +124 -0
- plot_io/lines.py +42 -0
- plot_io/mapped.py +522 -0
- plot_io/markers.py +110 -0
- plot_io/markup.py +128 -0
- plot_io/numbers.py +354 -0
- plot_io/occupancy.py +295 -0
- plot_io/plot.py +597 -0
- plot_io/presets/__init__.py +51 -0
- plot_io/presets/_util.py +153 -0
- plot_io/presets/area.py +145 -0
- plot_io/presets/bar.py +261 -0
- plot_io/presets/box.py +400 -0
- plot_io/presets/catpoints.py +177 -0
- plot_io/presets/contour.py +353 -0
- plot_io/presets/curves.py +30 -0
- plot_io/presets/dots.py +355 -0
- plot_io/presets/ecdf.py +97 -0
- plot_io/presets/errorbar.py +106 -0
- plot_io/presets/errors.py +296 -0
- plot_io/presets/fill.py +74 -0
- plot_io/presets/heatmap.py +322 -0
- plot_io/presets/hexbin.py +198 -0
- plot_io/presets/hist.py +391 -0
- plot_io/presets/hist2d.py +87 -0
- plot_io/presets/kde.py +162 -0
- plot_io/presets/kde2d.py +147 -0
- plot_io/presets/line.py +143 -0
- plot_io/presets/pie.py +502 -0
- plot_io/presets/regression.py +115 -0
- plot_io/presets/scatter.py +194 -0
- plot_io/presets/stem.py +100 -0
- plot_io/presets/violin.py +323 -0
- plot_io/py.typed +0 -0
- plot_io/registry.py +82 -0
- plot_io/render.py +480 -0
- plot_io/rows.py +491 -0
- plot_io/scales.py +103 -0
- plot_io/seams.py +138 -0
- plot_io/settings.py +807 -0
- plot_io/shapes.py +66 -0
- plot_io/sharing.py +232 -0
- plot_io/sizemaps.py +97 -0
- plot_io/spec.py +240 -0
- plot_io/states.py +216 -0
- plot_io/styles/__init__.py +307 -0
- plot_io/styles/library.py +397 -0
- plot_io/tables.py +138 -0
- plot_io/text.py +412 -0
- plot_io/ticks.py +400 -0
- plot_io/times.py +287 -0
- plot_io/titles.py +225 -0
- plot_io/transform.py +114 -0
- plot_io/types.py +13604 -0
- plot_io/units.py +768 -0
- plot_io/validators.py +1029 -0
- plot_io/values.py +190 -0
- plot_io-0.12.0.dist-info/METADATA +215 -0
- plot_io-0.12.0.dist-info/RECORD +130 -0
- plot_io-0.12.0.dist-info/WHEEL +4 -0
- plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/layers/stats.py
ADDED
|
@@ -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}
|