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/helpers/qq.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Quantile–quantile plots (stat ``qq``): a sorted sample against theoretical quantiles,
|
|
2
|
+
or against another sample, with a reference line and a pointwise band of order
|
|
3
|
+
statistics."""
|
|
4
|
+
|
|
5
|
+
import array
|
|
6
|
+
import bisect
|
|
7
|
+
import math
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .. import Scatter, ext, kernels
|
|
11
|
+
from .._methods import QQMixin
|
|
12
|
+
|
|
13
|
+
__all__ = ["QQ"]
|
|
14
|
+
|
|
15
|
+
#: Quantile functions of the named distributions.
|
|
16
|
+
DISTS: dict[str, Any] = {
|
|
17
|
+
"normal": ext.norm_quantile,
|
|
18
|
+
"uniform": lambda p: p,
|
|
19
|
+
"exponential": lambda p: -math.log1p(-p),
|
|
20
|
+
}
|
|
21
|
+
#: Ranks of the band at most.
|
|
22
|
+
BAND_RANKS = 512
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _dist(v: Any) -> Any:
|
|
26
|
+
if callable(v):
|
|
27
|
+
return v
|
|
28
|
+
return ext.one_of(*DISTS)(v)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _line(v: Any) -> Any:
|
|
32
|
+
if v is None or v is False:
|
|
33
|
+
return None
|
|
34
|
+
return ext.one_of("auto", "quartiles", "fit", "identity")(v)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _band(v: Any) -> Any:
|
|
38
|
+
if v is None or v is False or v == "auto":
|
|
39
|
+
return None if v is False else v
|
|
40
|
+
return ext.number(0, 1)(v)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class QQ(QQMixin, Scatter):
|
|
44
|
+
"""The sorted ``values`` against the quantiles of ``dist`` at R's ``ppoints``
|
|
45
|
+
(``(i − a) / (n + 1 − 2a)``, a = 3/8 for n ≤ 10, else 1/2), or against the sorted ``y``
|
|
46
|
+
(two samples: the longer interpolated to the shorter's size, as R's ``qqplot``;
|
|
47
|
+
``values`` on x). ``pvalues=True``: ``values`` are p-values, drawn as −log10 of the
|
|
48
|
+
sorted p-values (y) against −log10 of the expected uniform quantiles (x). Points as
|
|
49
|
+
`Scatter`'s; the reference ``line`` and the pointwise ``band`` of order statistics in
|
|
50
|
+
the series color. The points and the default line are R's (``qqnorm``, ``qqline``), not
|
|
51
|
+
scipy's ``probplot`` (Filliben's medians, a least-squares line).
|
|
52
|
+
|
|
53
|
+
``dist``: ``normal`` (Wichura's AS241, full double precision), ``uniform``,
|
|
54
|
+
``exponential``, or a function p → quantile. With ``pvalues=True``, values outside
|
|
55
|
+
(0, 1] are left out.
|
|
56
|
+
|
|
57
|
+
- ``line``: ``quartiles`` (through the first and third quartile points, R's ``qqline``),
|
|
58
|
+
``fit`` (least squares), ``identity``, or ``None``; ``"auto"``: ``identity`` with
|
|
59
|
+
``pvalues``, else ``quartiles``.
|
|
60
|
+
- ``band``: the level of the pointwise band of order statistics (beta quantiles, mapped
|
|
61
|
+
through the line), on at most 512 ranks spread evenly along the sample axis;
|
|
62
|
+
``"auto"``: 0.95 with ``pvalues``, else none.
|
|
63
|
+
- Markers have no edge by default. Above 10,000 points with one marker style, points
|
|
64
|
+
within 1e-4 of the data range of the last one drawn are not drawn (``stats`` keeps
|
|
65
|
+
them all).
|
|
66
|
+
|
|
67
|
+
``qq.stats``: ``x``, ``y``, ``line`` (slope, intercept), ``band_x``, ``low``, ``high``.
|
|
68
|
+
Uses::
|
|
69
|
+
|
|
70
|
+
p.qq(residuals) # against the normal, quartile line
|
|
71
|
+
p.qq(sample, other_sample) # two samples
|
|
72
|
+
p.qq(pvalues, pvalues=True) # genomics QQ: −log10, identity, 95 % band
|
|
73
|
+
p.qq(x, dist=scipy.stats.t(5).ppf, band=0.9) # any quantile function
|
|
74
|
+
|
|
75
|
+
Example::
|
|
76
|
+
|
|
77
|
+
rng = random.Random(1)
|
|
78
|
+
p = plt.Plot(width=240, height=160)
|
|
79
|
+
p.qq([rng.gauss(0, 1) for _ in range(150)], band=0.95)
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
SETTINGS = ext.table(
|
|
83
|
+
**{k: v for k, v in Scatter.SETTINGS.items()
|
|
84
|
+
if k not in ("x_error", "y_error", "orientation") and not k.startswith("label")},
|
|
85
|
+
dist=ext.Setting(_dist, doc="`normal`, `uniform`, `exponential`, or a function "
|
|
86
|
+
"p → quantile (`scipy.stats.t(5).ppf`)."),
|
|
87
|
+
pvalues=ext.Setting(ext.boolean, doc="`values` are p-values: −log10 of both, the "
|
|
88
|
+
"identity line and a 95 % band by default."),
|
|
89
|
+
line=ext.Setting(_line, doc="`quartiles` (through the first and third quartiles, "
|
|
90
|
+
"R's `qqline`), `fit` (least squares), `identity`, "
|
|
91
|
+
"`None`; `auto`: `identity` with `pvalues`, else "
|
|
92
|
+
"`quartiles`."),
|
|
93
|
+
band=ext.Setting(_band, doc="Level of the pointwise band of order statistics (beta "
|
|
94
|
+
"quantiles, on the line), `None`; `auto`: 0.95 with "
|
|
95
|
+
"`pvalues`, else none."),
|
|
96
|
+
band_opacity=ext.Setting(ext.number(0, 1), doc="Opacity of the band."),
|
|
97
|
+
**ext.common("line_width"),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
DATA = {"values": "numbers", "y": "raw"}
|
|
101
|
+
|
|
102
|
+
def __init__(self, values: Any, y: Any = None, plot: Any = None,
|
|
103
|
+
**settings: Any) -> None:
|
|
104
|
+
ext.Series.__init__(self, plot=plot, **settings)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def stats(self) -> dict[str, Any]:
|
|
108
|
+
"""``x``, ``y`` (the points), ``line`` (``(slope, intercept)`` or ``None``), and the
|
|
109
|
+
band: ``band_x``, ``low``, ``high`` (``None`` without a band)."""
|
|
110
|
+
return ext.compute(self, "qq")
|
|
111
|
+
|
|
112
|
+
def density_points(self) -> tuple[Any, Any]:
|
|
113
|
+
st = self.stats
|
|
114
|
+
return st["x"], st["y"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _sorted(values: Any, positive: bool = False) -> array.array:
|
|
118
|
+
"""The finite values sorted (``positive``: those in (0, 1])."""
|
|
119
|
+
v = kernels.doubles(values)
|
|
120
|
+
s = kernels.take(v, kernels.argsort(v)) if len(v) else array.array("d")
|
|
121
|
+
i = 0
|
|
122
|
+
while i < len(s) and math.isnan(s[i]):
|
|
123
|
+
i += 1
|
|
124
|
+
lo = bisect.bisect_right(s, 0.0 if positive else -math.inf, i)
|
|
125
|
+
hi = bisect.bisect_right(s, 1.0, lo) if positive else bisect.bisect_left(s, math.inf, lo)
|
|
126
|
+
return s[lo:hi]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _ppoints(n: int) -> tuple[float, float]:
|
|
130
|
+
"""``a`` and the denominator of R's ``ppoints(n)``."""
|
|
131
|
+
a = 3 / 8 if n <= 10 else 0.5
|
|
132
|
+
return a, n + 1 - 2 * a
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _interpolated(s: array.array, n: int) -> array.array:
|
|
136
|
+
"""Sorted ``s`` at ``n`` positions spread evenly over its ranks (``np.interp`` at
|
|
137
|
+
``linspace(1, len, n)``)."""
|
|
138
|
+
m = len(s)
|
|
139
|
+
if n == 1 or m == 1:
|
|
140
|
+
return array.array("d", [s[0]] * n)
|
|
141
|
+
step = (m - 1) / (n - 1)
|
|
142
|
+
out = array.array("d")
|
|
143
|
+
for k in range(n):
|
|
144
|
+
t = 1 + k * step if k < n - 1 else float(m)
|
|
145
|
+
j = min(int(t) - 1, m - 2)
|
|
146
|
+
out.append((s[j + 1] - s[j]) * (t - (j + 1)) + s[j])
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _quartiles(xs: Any, ys: Any) -> tuple[float, float] | None:
|
|
151
|
+
(x1, x3), (y1, y3) = xs, ys
|
|
152
|
+
if not x3 - x1:
|
|
153
|
+
return None
|
|
154
|
+
slope = (y3 - y1) / (x3 - x1)
|
|
155
|
+
return slope, y1 - slope * x1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _fit(x: Any, y: Any) -> tuple[float, float] | None:
|
|
159
|
+
n = len(x)
|
|
160
|
+
if n < 2:
|
|
161
|
+
return None
|
|
162
|
+
mx, my = math.fsum(x) / n, math.fsum(y) / n
|
|
163
|
+
sxx = math.fsum((a - mx) * (a - mx) for a in x)
|
|
164
|
+
if not sxx:
|
|
165
|
+
return None
|
|
166
|
+
slope = math.fsum((a - mx) * (b - my) for a, b in zip(x, y)) / sxx
|
|
167
|
+
return slope, my - slope * mx
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _band_ranks(n: int, along: Any) -> list[int]:
|
|
171
|
+
"""At most :data:`BAND_RANKS` ranks (1-based), spread evenly along ``along`` (sorted
|
|
172
|
+
positions of the ranks, ascending or descending)."""
|
|
173
|
+
if n <= BAND_RANKS:
|
|
174
|
+
return list(range(1, n + 1))
|
|
175
|
+
sign = -1.0 if along[0] > along[-1] else 1.0
|
|
176
|
+
lo, hi = sign * along[0], sign * along[-1]
|
|
177
|
+
out: list[int] = []
|
|
178
|
+
for k in range(BAND_RANKS):
|
|
179
|
+
t = lo + (hi - lo) * k / (BAND_RANKS - 1)
|
|
180
|
+
i = min(bisect.bisect_left(along, t, key=lambda v: sign * v), n - 1) + 1
|
|
181
|
+
if not out or i > out[-1]:
|
|
182
|
+
out.append(i)
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@ext.stat("qq", reads=("dist", "line", "band", "pvalues"))
|
|
187
|
+
def _qq(cols: Any, p: Any, scales: Any = ()) -> dict[str, Any]:
|
|
188
|
+
pv = bool(p.get("pvalues"))
|
|
189
|
+
line, level = p.get("line", "auto"), p.get("band", "auto")
|
|
190
|
+
if line == "auto":
|
|
191
|
+
line = "identity" if pv else "quartiles"
|
|
192
|
+
if level == "auto":
|
|
193
|
+
level = 0.95 if pv else None
|
|
194
|
+
sample = _sorted(cols["values"], pv)
|
|
195
|
+
other = cols["y"]
|
|
196
|
+
n = len(sample)
|
|
197
|
+
out: dict[str, Any] = {"x": array.array("d"), "y": array.array("d"), "line": None,
|
|
198
|
+
"band_x": None, "low": None, "high": None}
|
|
199
|
+
if not n:
|
|
200
|
+
return out
|
|
201
|
+
if other is not None:
|
|
202
|
+
ys = _sorted(other)
|
|
203
|
+
if not len(ys):
|
|
204
|
+
return out
|
|
205
|
+
xs = _interpolated(sample, len(ys)) if n > len(ys) else sample
|
|
206
|
+
ys = _interpolated(ys, n) if len(ys) > n else ys
|
|
207
|
+
out["x"], out["y"] = xs, ys
|
|
208
|
+
if line == "quartiles":
|
|
209
|
+
out["line"] = _quartiles(kernels.quantiles(sample, (0.25, 0.75)),
|
|
210
|
+
kernels.quantiles(_sorted(other), (0.25, 0.75)))
|
|
211
|
+
elif line == "fit":
|
|
212
|
+
out["line"] = _fit(xs, ys)
|
|
213
|
+
elif line == "identity":
|
|
214
|
+
out["line"] = (1.0, 0.0)
|
|
215
|
+
return out
|
|
216
|
+
a, den = _ppoints(n)
|
|
217
|
+
if pv:
|
|
218
|
+
q = kernels.scaled(array.array("d", range(1, n + 1)), 1 / den, -a / den)
|
|
219
|
+
out["x"] = kernels.scaled(q, -1.0, 0.0, "log10")
|
|
220
|
+
out["y"] = kernels.scaled(sample, -1.0, 0.0, "log10")
|
|
221
|
+
f = None
|
|
222
|
+
else:
|
|
223
|
+
f = p.get("dist", "normal")
|
|
224
|
+
f = DISTS[f] if isinstance(f, str) else f
|
|
225
|
+
out["x"] = array.array("d", (float(f((i - a) / den)) for i in range(1, n + 1)))
|
|
226
|
+
out["y"] = sample
|
|
227
|
+
x, y = out["x"], out["y"]
|
|
228
|
+
if line == "quartiles":
|
|
229
|
+
if f is None:
|
|
230
|
+
qx = [-math.log10(0.75), -math.log10(0.25)]
|
|
231
|
+
else:
|
|
232
|
+
qx = [float(f(0.25)), float(f(0.75))]
|
|
233
|
+
out["line"] = _quartiles(qx, kernels.quantiles(y, (0.25, 0.75)))
|
|
234
|
+
elif line == "fit":
|
|
235
|
+
out["line"] = _fit(x, y)
|
|
236
|
+
elif line == "identity":
|
|
237
|
+
out["line"] = (1.0, 0.0)
|
|
238
|
+
if level is None:
|
|
239
|
+
return out
|
|
240
|
+
slope, icpt = out["line"] or _quartiles(
|
|
241
|
+
[float(f(0.25)), float(f(0.75))] if f is not None else [0.0, 1.0],
|
|
242
|
+
kernels.quantiles(y, (0.25, 0.75))) or (1.0, 0.0)
|
|
243
|
+
ranks = _band_ranks(n, x if pv else y)
|
|
244
|
+
lows, highs, bx = array.array("d"), array.array("d"), array.array("d")
|
|
245
|
+
for i in ranks:
|
|
246
|
+
lo = ext.beta_quantile((1 - level) / 2, i, n + 1 - i)
|
|
247
|
+
hi = ext.beta_quantile((1 + level) / 2, i, n + 1 - i)
|
|
248
|
+
if f is None:
|
|
249
|
+
pair = (-math.log10(hi), -math.log10(lo))
|
|
250
|
+
else:
|
|
251
|
+
pair = (slope * float(f(lo)) + icpt, slope * float(f(hi)) + icpt)
|
|
252
|
+
lows.append(min(pair))
|
|
253
|
+
highs.append(max(pair))
|
|
254
|
+
bx.append(x[i - 1])
|
|
255
|
+
out["band_x"], out["low"], out["high"] = bx, lows, highs
|
|
256
|
+
return out
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
#: Points of a QQ plot drawn whole at most; above, points closer to the last one kept than
|
|
260
|
+
#: 1e-4 of the data's range on both axes (well under a pixel) are left out, when the
|
|
261
|
+
#: markers are the same for every point.
|
|
262
|
+
THIN = 10_000
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _points(s: QQ, b: Any) -> Any:
|
|
266
|
+
return s.cached("qq_points", lambda: _thinned(s),
|
|
267
|
+
("dist", "line", "band", "pvalues", "color", "size", "marker",
|
|
268
|
+
"edge_color"))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _thinned(s: QQ) -> Any:
|
|
272
|
+
st = s.stats
|
|
273
|
+
x, y = st["x"], st["y"]
|
|
274
|
+
n = len(x)
|
|
275
|
+
if not n:
|
|
276
|
+
return None
|
|
277
|
+
each = any(hasattr(v, "__len__") and not isinstance(v, str) and len(v) == n
|
|
278
|
+
for v in (s.resolve(k) for k in ("color", "size", "marker", "edge_color")))
|
|
279
|
+
if n <= THIN or each:
|
|
280
|
+
return ext.Table(x=x, y=y)
|
|
281
|
+
ex = (max(x) - min(x)) * 1e-4
|
|
282
|
+
ey = (max(y) - min(y)) * 1e-4
|
|
283
|
+
keep = array.array("q", [0])
|
|
284
|
+
lx, ly = x[0], y[0]
|
|
285
|
+
for i in range(1, n):
|
|
286
|
+
if abs(x[i] - lx) > ex or abs(y[i] - ly) > ey:
|
|
287
|
+
keep.append(i)
|
|
288
|
+
lx, ly = x[i], y[i]
|
|
289
|
+
if keep[-1] != n - 1:
|
|
290
|
+
keep.append(n - 1)
|
|
291
|
+
return ext.Table(x=kernels.take(x, keep), y=kernels.take(y, keep))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _line_table(s: QQ, b: Any) -> Any:
|
|
295
|
+
st = s.stats
|
|
296
|
+
if st["line"] is None or not len(st["x"]):
|
|
297
|
+
return None
|
|
298
|
+
slope, icpt = st["line"]
|
|
299
|
+
x0, x1 = min(st["x"]), max(st["x"])
|
|
300
|
+
if st["band_x"] is not None:
|
|
301
|
+
x0, x1 = min(x0, min(st["band_x"])), max(x1, max(st["band_x"]))
|
|
302
|
+
return ext.Table(line_x=[x0, x1], line_y=[slope * x0 + icpt, slope * x1 + icpt])
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _band_table(s: QQ, b: Any) -> Any:
|
|
306
|
+
st = s.stats
|
|
307
|
+
if st["low"] is None:
|
|
308
|
+
return None
|
|
309
|
+
return ext.Table(x=st["band_x"], y1=st["low"], y2=st["high"])
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
QQ.SPEC = ext.Spec(
|
|
313
|
+
[ext.Lay("band", ("band", {"color": "faded", "opacity": "band_opacity"}), table="band",
|
|
314
|
+
key=False),
|
|
315
|
+
ext.Lay("path", ("stroke", {"color": "faded"}), table="line",
|
|
316
|
+
aes={"x": "line_x", "y": "line_y"}, key=False, bounds=False),
|
|
317
|
+
ext.Lay("points", ("marker", {"order": True, "once": True,
|
|
318
|
+
"n": lambda s: _count(s)}),
|
|
319
|
+
table="points", name="points", radius=lambda s: _radius(s.resolve("size")))],
|
|
320
|
+
tables={"points": _points, "line": _line_table, "band": _band_table},
|
|
321
|
+
key={"fill": "marker", "stroke": "marker", "marker": "@points"},
|
|
322
|
+
color_sources=True, size_sources=True)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _count(s: QQ) -> int:
|
|
326
|
+
t = _points(s, None)
|
|
327
|
+
return 0 if t is None else len(t["x"])
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _radius(size: Any) -> float:
|
|
331
|
+
return size / 2 if isinstance(size, (int, float)) else 0.0
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
ext.register("qq", QQ)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Raincloud plots: per category a half violin (the cloud), a narrow box at the slot's
|
|
2
|
+
centre and the values as jittered points on the other side (the rain)."""
|
|
3
|
+
|
|
4
|
+
import array
|
|
5
|
+
import random
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .. import columns, ext
|
|
9
|
+
from .._methods import RaincloudMixin
|
|
10
|
+
|
|
11
|
+
__all__ = ["Raincloud"]
|
|
12
|
+
|
|
13
|
+
PARTS = ("violin", "box", "points")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parts(v: Any) -> tuple[str, ...]:
|
|
17
|
+
if isinstance(v, str):
|
|
18
|
+
v = (v,)
|
|
19
|
+
out = tuple(v)
|
|
20
|
+
for p in out:
|
|
21
|
+
ext.one_of(*PARTS)(p)
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Raincloud(RaincloudMixin, ext.Series):
|
|
26
|
+
"""Rainclouds of ``values`` (a dict ``{category: values}``, a list of arrays, or one
|
|
27
|
+
array; long data with ``x=`` categories): per category a half violin on the high side
|
|
28
|
+
(stat ``violin``), a narrow box at the centre (stat ``box``) and the values as
|
|
29
|
+
jittered points on the low side. ``parts`` keeps some of the three. Widths and
|
|
30
|
+
offsets are `%` of the slot.
|
|
31
|
+
|
|
32
|
+
Long data take ``x=`` the categories (``y=`` with ``orientation="horizontal"``).
|
|
33
|
+
Densities and quartiles are the ``violin`` and ``box`` stats. Rainclouds do not dodge
|
|
34
|
+
with other series and take no levels. The same halves and shifts work on the plain
|
|
35
|
+
types: ``Violin(side="high")``, ``Box(offset=…)``, ``Strip(offset=…)``. Uses::
|
|
36
|
+
|
|
37
|
+
p.raincloud({"control": a, "treated": b})
|
|
38
|
+
p.raincloud("value", x="group", data=df, parts=("violin", "points"))
|
|
39
|
+
|
|
40
|
+
Example::
|
|
41
|
+
|
|
42
|
+
rng = random.Random(1)
|
|
43
|
+
p = plt.Plot(width=240, height=160)
|
|
44
|
+
p.raincloud({"control": [rng.gauss(0, 1) for _ in range(60)],
|
|
45
|
+
"treated": [rng.gauss(1, 0.8) for _ in range(60)]})
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
SETTINGS = ext.table(
|
|
49
|
+
color=ext.Setting(ext.color, doc="Color of the three parts; default the next "
|
|
50
|
+
"plot's color."),
|
|
51
|
+
parts=ext.Setting(_parts, doc="The parts drawn: `violin`, `box`, `points`."),
|
|
52
|
+
orientation=ext.Setting(ext.one_of("vertical", "horizontal"),
|
|
53
|
+
doc="`vertical` (categories on x) or `horizontal`."),
|
|
54
|
+
width=ext.Setting(ext.length(positive=True, data=True, percent="slot"),
|
|
55
|
+
doc="Width of the slot the raincloud takes (the axis limits at "
|
|
56
|
+
"numeric positions)."),
|
|
57
|
+
violin_width=ext.Setting(ext.length(positive=True, percent="slot"),
|
|
58
|
+
doc="Largest width of the half violins."),
|
|
59
|
+
box_width=ext.Setting(ext.length(positive=True, percent="slot"),
|
|
60
|
+
doc="Width of the boxes."),
|
|
61
|
+
points_offset=ext.Setting(ext.length(percent="slot"),
|
|
62
|
+
doc="Centre of the points from the slot's centre "
|
|
63
|
+
"(negative: the low side)."),
|
|
64
|
+
jitter=ext.Setting(ext.length(lo=0, percent="slot"),
|
|
65
|
+
doc="Half-width of the points' jitter."),
|
|
66
|
+
seed=ext.Setting(ext.number(integer=True), doc="Seed of the jitter."),
|
|
67
|
+
fill_opacity=ext.Setting(ext.number(0, 1), doc="Opacity of the violins' and "
|
|
68
|
+
"boxes' fill."),
|
|
69
|
+
bandwidth=ext.Setting(ext.either(ext.one_of("scott", "silverman"),
|
|
70
|
+
ext.number(positive=True)),
|
|
71
|
+
doc="`scott`, `silverman` or a number."),
|
|
72
|
+
adjust=ext.Setting(ext.number(positive=True), doc="Factor applied to the bandwidth."),
|
|
73
|
+
cut=ext.Setting(ext.number(lo=0), doc="The density extends this many bandwidths "
|
|
74
|
+
"past the data."),
|
|
75
|
+
points=ext.Setting(ext.number(lo=16, hi=65536, integer=True),
|
|
76
|
+
doc="Points per violin outline."),
|
|
77
|
+
whiskers=ext.Setting(ext.number(lo=0), doc="Whisker reach in IQRs."),
|
|
78
|
+
weights=ext.Setting(columns.weights,
|
|
79
|
+
doc="A weight per value, counted as repeated values, for the "
|
|
80
|
+
"violins and boxes (a dict or a list per category, as the "
|
|
81
|
+
"values; long data: a weight per row)."),
|
|
82
|
+
median_width=ext.Setting(ext.length(lo=0), doc="Width of the median line."),
|
|
83
|
+
**ext.group("Marker", "marker_"),
|
|
84
|
+
categories=ext.Setting(ext.either(ext.literal(None), lambda v: list(v)),
|
|
85
|
+
doc="Category names of a list of arrays."),
|
|
86
|
+
**ext.common("line_width", "name", "opacity", "z", "rasterize", "simplify", "font_size",
|
|
87
|
+
"font_family", "text_color"),
|
|
88
|
+
)
|
|
89
|
+
DATA = {"values": "categories", "positions": "slots"}
|
|
90
|
+
ITEMS = "rainclouds"
|
|
91
|
+
|
|
92
|
+
def __init__(self, values: Any, positions: Any = None, plot: Any = None,
|
|
93
|
+
**settings: Any) -> None:
|
|
94
|
+
super().__init__(plot=plot, **settings)
|
|
95
|
+
|
|
96
|
+
def value_scale(self) -> Any:
|
|
97
|
+
return ext.axis_scale(ext.axis_of(self, "y"))
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def stats(self) -> dict[Any, dict[str, Any]]:
|
|
101
|
+
"""``{category: {"violin": the violin stat's, "box": BoxStats}}``."""
|
|
102
|
+
v = ext.compute(self, "violin", (self.value_scale(),))
|
|
103
|
+
b = ext.compute(self, "box")
|
|
104
|
+
return {k: {"violin": v[k], "box": b[k]} for k in self.cols.categories}
|
|
105
|
+
|
|
106
|
+
def base(self, ctx: Any) -> Any:
|
|
107
|
+
"""The series color."""
|
|
108
|
+
c = self.resolve("color")
|
|
109
|
+
return ctx.color(c) if c is not None else ext.auto_colors(self, ctx)[0]
|
|
110
|
+
|
|
111
|
+
def fill(self, ctx: Any) -> Any:
|
|
112
|
+
"""The violins' and boxes' fill."""
|
|
113
|
+
return ext.with_alpha(self.base(ctx),
|
|
114
|
+
self.resolve("opacity") * self.resolve("fill_opacity"))
|
|
115
|
+
|
|
116
|
+
def _on(self, part: str) -> bool:
|
|
117
|
+
return part in self.resolve("parts")
|
|
118
|
+
|
|
119
|
+
def _scale(self) -> Any:
|
|
120
|
+
"""The scale of numeric positions' axis (``None``: none, or linear)."""
|
|
121
|
+
if self.cols["positions"] is None:
|
|
122
|
+
return None
|
|
123
|
+
sc = ext.axis_scale(ext.axis_of(self, "x"))
|
|
124
|
+
return sc if sc.function else None
|
|
125
|
+
|
|
126
|
+
def _slots(self) -> tuple[list[float], float]:
|
|
127
|
+
"""The centre of each category's slot and the slot's width, in the space of the
|
|
128
|
+
positions' axis scale (:meth:`_back` maps them to data)."""
|
|
129
|
+
pos = self.cols["positions"]
|
|
130
|
+
if pos is None:
|
|
131
|
+
return [float(i) for i in range(len(self.cols.categories))], 1.0
|
|
132
|
+
sc = self._scale()
|
|
133
|
+
fs = [sc.f(float(p)) if sc is not None else float(p) for p in pos]
|
|
134
|
+
ps = sorted(set(fs))
|
|
135
|
+
gaps = [b - a for a, b in zip(ps, ps[1:]) if b > a]
|
|
136
|
+
return fs, (min(gaps) if gaps else 1.0)
|
|
137
|
+
|
|
138
|
+
def _back(self, v: Any) -> Any:
|
|
139
|
+
"""Slot-space positions in data units."""
|
|
140
|
+
sc = self._scale()
|
|
141
|
+
if sc is None:
|
|
142
|
+
return v
|
|
143
|
+
if isinstance(v, list) and v and not isinstance(v[0], float):
|
|
144
|
+
return [self._back(a) for a in v]
|
|
145
|
+
return array.array("d", (sc.inverse(x) for x in v))
|
|
146
|
+
|
|
147
|
+
def _width(self, name: str, unit: float) -> float:
|
|
148
|
+
return float(self.resolve(name, of=unit))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _clouds(s: Raincloud, b: Any) -> Any:
|
|
152
|
+
st = s.stats
|
|
153
|
+
centres, unit = s._slots()
|
|
154
|
+
keys = [i for i, k in enumerate(s.cols.categories)
|
|
155
|
+
if st[k]["violin"]["count"] and not st[k]["violin"]["line"]]
|
|
156
|
+
if not keys:
|
|
157
|
+
return None
|
|
158
|
+
peak = max(max(st[s.cols.categories[i]]["violin"]["density"]) for i in keys) or 1.0
|
|
159
|
+
w = s._width("violin_width", unit)
|
|
160
|
+
xs, lo, hi = [], [], []
|
|
161
|
+
for i in keys:
|
|
162
|
+
v = st[s.cols.categories[i]]["violin"]
|
|
163
|
+
c = centres[i]
|
|
164
|
+
xs.append(v["grid"])
|
|
165
|
+
lo.append(array.array("d", [c] * len(v["grid"])))
|
|
166
|
+
hi.append(array.array("d", (c + d / peak * w for d in v["density"])))
|
|
167
|
+
return ext.Table(x=xs, y1=s._back(lo), y2=s._back(hi))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _boxes(s: Raincloud, b: Any) -> Any:
|
|
171
|
+
st = s.stats
|
|
172
|
+
centres, unit = s._slots()
|
|
173
|
+
half = s._width("box_width", unit) / 2
|
|
174
|
+
rows = [(centres[i], st[k]["box"]) for i, k in enumerate(s.cols.categories)
|
|
175
|
+
if st[k]["box"].count]
|
|
176
|
+
if not rows:
|
|
177
|
+
return None
|
|
178
|
+
return ext.Table(x0=s._back([c - half for c, _ in rows]),
|
|
179
|
+
x1=s._back([c + half for c, _ in rows]),
|
|
180
|
+
y0=[bs.q1 for _, bs in rows], y1=[bs.q3 for _, bs in rows])
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _whiskers(s: Raincloud, b: Any) -> Any:
|
|
184
|
+
st = s.stats
|
|
185
|
+
centres, _ = s._slots()
|
|
186
|
+
t: dict[str, list[float]] = {"x0": [], "x1": [], "y0": [], "y1": []}
|
|
187
|
+
for i, k in enumerate(s.cols.categories):
|
|
188
|
+
bs = st[k]["box"]
|
|
189
|
+
if bs.count:
|
|
190
|
+
for a, e in ((bs.q1, bs.low), (bs.q3, bs.high)):
|
|
191
|
+
t["x0"].append(centres[i])
|
|
192
|
+
t["x1"].append(centres[i])
|
|
193
|
+
t["y0"].append(a)
|
|
194
|
+
t["y1"].append(e)
|
|
195
|
+
if not t["x0"]:
|
|
196
|
+
return None
|
|
197
|
+
return ext.Table(x0=s._back(t["x0"]), x1=s._back(t["x1"]), y0=t["y0"], y1=t["y1"])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _medians(s: Raincloud, b: Any) -> Any:
|
|
201
|
+
st = s.stats
|
|
202
|
+
centres, unit = s._slots()
|
|
203
|
+
half = s._width("box_width", unit) / 2
|
|
204
|
+
rows = [(centres[i], st[k]["box"].median) for i, k in enumerate(s.cols.categories)
|
|
205
|
+
if st[k]["box"].count]
|
|
206
|
+
if not rows:
|
|
207
|
+
return None
|
|
208
|
+
return ext.Table(x0=s._back([c - half for c, _ in rows]),
|
|
209
|
+
x1=s._back([c + half for c, _ in rows]),
|
|
210
|
+
y0=[m for _, m in rows], y1=[m for _, m in rows])
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _rain(s: Raincloud, b: Any) -> Any:
|
|
214
|
+
centres, unit = s._slots()
|
|
215
|
+
off = s._width("points_offset", unit)
|
|
216
|
+
jit = s._width("jitter", unit)
|
|
217
|
+
rng = random.Random(s.resolve("seed"))
|
|
218
|
+
x, y = array.array("d"), array.array("d")
|
|
219
|
+
for c, vals in zip(centres, s.cols.values):
|
|
220
|
+
for v in vals:
|
|
221
|
+
x.append(c + off + jit * rng.uniform(-1.0, 1.0))
|
|
222
|
+
y.append(v)
|
|
223
|
+
return ext.Table(x=s._back(x), y=y) if len(y) else None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@ext.role("raincloud.box")
|
|
227
|
+
def _box_paint(s: Raincloud, ctx: Any, glyph: bool) -> dict[str, Any]:
|
|
228
|
+
return {"fill": s.fill(ctx), "stroke": ext.with_alpha(s.base(ctx), s.resolve("opacity")),
|
|
229
|
+
"line_width": s.resolve("line_width")}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
Raincloud.SPEC = ext.Spec(
|
|
233
|
+
[ext.Lay("band", ("band", {"color": "fill()", "extra": ("simplify",)}),
|
|
234
|
+
table="clouds", when=lambda s: s._on("violin"), turn=True, key=False),
|
|
235
|
+
ext.Lay("segments", ("stroke", {"color": "faded"}), table="whiskers",
|
|
236
|
+
when=lambda s: s._on("box"), key=False),
|
|
237
|
+
ext.Lay("rects", "raincloud.box", table="boxes", when=lambda s: s._on("box"),
|
|
238
|
+
key=False),
|
|
239
|
+
ext.Lay("segments", ("stroke", {"color": "faded", "width": "median_width"}),
|
|
240
|
+
table="medians", when=lambda s: s._on("box"), key=False),
|
|
241
|
+
ext.Lay("points", ("marker", {"prefix": "marker_", "color": "base",
|
|
242
|
+
"n": lambda s: sum(map(len, s.cols.values))}),
|
|
243
|
+
table="rain", name="rain", when=lambda s: s._on("points"))],
|
|
244
|
+
tables={"clouds": _clouds, "boxes": _boxes, "whiskers": _whiskers, "medians": _medians,
|
|
245
|
+
"rain": _rain},
|
|
246
|
+
key={"fill": "base", "stroke": "base"},
|
|
247
|
+
titles={"values": "y", "categories": "x"}, slots="positions")
|
|
248
|
+
|
|
249
|
+
ext.register("raincloud", Raincloud)
|