plot-io 0.1.0__py3-none-any.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 +66 -0
- plot_io/_backend.py +114 -0
- plot_io/axis.py +574 -0
- plot_io/colorbar.py +342 -0
- plot_io/decorations.py +496 -0
- plot_io/defaults.py +766 -0
- plot_io/density.py +97 -0
- plot_io/enums.py +63 -0
- plot_io/facets.py +184 -0
- plot_io/figure.py +331 -0
- plot_io/labels.py +63 -0
- plot_io/legend.py +498 -0
- plot_io/limits.py +92 -0
- plot_io/maps/__init__.py +365 -0
- plot_io/maps/data.py +333 -0
- plot_io/markers.py +117 -0
- plot_io/numbers.py +289 -0
- plot_io/occupancy.py +219 -0
- plot_io/palettes.py +142 -0
- plot_io/plot.py +435 -0
- plot_io/render.py +1122 -0
- plot_io/scales.py +93 -0
- plot_io/series/__init__.py +498 -0
- plot_io/series/area.py +181 -0
- plot_io/series/bar.py +294 -0
- plot_io/series/box.py +401 -0
- plot_io/series/catdata.py +23 -0
- plot_io/series/catpoints.py +182 -0
- plot_io/series/contour.py +294 -0
- plot_io/series/curves.py +128 -0
- plot_io/series/ecdf.py +143 -0
- plot_io/series/errorbar.py +111 -0
- plot_io/series/errors.py +100 -0
- plot_io/series/fill.py +168 -0
- plot_io/series/heatmap.py +304 -0
- plot_io/series/hexbin.py +216 -0
- plot_io/series/hist.py +379 -0
- plot_io/series/hist2d.py +163 -0
- plot_io/series/kde.py +228 -0
- plot_io/series/kde2d.py +153 -0
- plot_io/series/line.py +173 -0
- plot_io/series/pie.py +304 -0
- plot_io/series/points.py +341 -0
- plot_io/series/regression.py +250 -0
- plot_io/series/scatter.py +103 -0
- plot_io/series/stem.py +112 -0
- plot_io/series/violin.py +321 -0
- plot_io/settings.py +281 -0
- plot_io/shapes.py +57 -0
- plot_io/sharing.py +160 -0
- plot_io/slots.py +52 -0
- plot_io/stacking.py +63 -0
- plot_io/stats.py +140 -0
- plot_io/style.py +71 -0
- plot_io/styles/__init__.py +234 -0
- plot_io/styles/library.py +732 -0
- plot_io/tables.py +141 -0
- plot_io/text.py +109 -0
- plot_io/ticks.py +284 -0
- plot_io/times.py +199 -0
- plot_io/values.py +161 -0
- plot_io-0.1.0.dist-info/METADATA +102 -0
- plot_io-0.1.0.dist-info/RECORD +65 -0
- plot_io-0.1.0.dist-info/WHEEL +4 -0
- plot_io-0.1.0.dist-info/licenses/LICENSE +28 -0
plot_io/series/points.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Markers shared by scatter, line and box series: settings and drawing."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .. import _backend
|
|
7
|
+
from ..maps import ColorMap
|
|
8
|
+
from ..markers import area_factor, check_marker, resolve
|
|
9
|
+
from ..settings import Setting, boolean, either, number, one_of
|
|
10
|
+
from ..style import resolve_color, with_alpha
|
|
11
|
+
from ..values import Values, is_computed, paint, size_value
|
|
12
|
+
|
|
13
|
+
__all__ = ["paint", "colors_only", "size_value", "marker_value", "marker_settings",
|
|
14
|
+
"MarkerStyle", "map_colors", "to_values", "order_settings", "order_index",
|
|
15
|
+
"permuted", "density_points"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def colors_only(v: Any) -> Any:
|
|
19
|
+
"""A color or a list of colors (series whose colors are per value set, not mapped)."""
|
|
20
|
+
out = paint(v)
|
|
21
|
+
if isinstance(out, Values) or is_computed(out):
|
|
22
|
+
raise ValueError("expected a color or a list of colors (values to map are not "
|
|
23
|
+
"accepted here)")
|
|
24
|
+
return out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def marker_value(v: Any) -> Any:
|
|
28
|
+
if isinstance(v, (list, tuple)):
|
|
29
|
+
return [check_marker(m) for m in v]
|
|
30
|
+
return check_marker(v)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def to_values(v: Any) -> Any:
|
|
34
|
+
if isinstance(v, dict):
|
|
35
|
+
return v
|
|
36
|
+
return _backend.doubles(v)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def marker_settings(prefix: str = "") -> dict[str, Setting]:
|
|
40
|
+
"""Settings of markers, named ``<prefix>marker``, ``<prefix>size``…"""
|
|
41
|
+
p = prefix
|
|
42
|
+
size_name = "size" if not p else "marker_size"
|
|
43
|
+
color_name = "color" if not p else "marker_color"
|
|
44
|
+
return {
|
|
45
|
+
"marker": Setting(marker_value,
|
|
46
|
+
doc="Marker name, `Path` or path data, or one per point."),
|
|
47
|
+
size_name: Setting(size_value,
|
|
48
|
+
doc="Marker size (pt): the diameter of the circle of equal area; "
|
|
49
|
+
"one value or one per point; numbers per point are values to map "
|
|
50
|
+
"when `size_map` is set; or `\"density\"`.", length=True),
|
|
51
|
+
color_name: Setting(lambda v: paint(v, color_name),
|
|
52
|
+
doc="Fill: a color, colors per point, numbers per point (mapped "
|
|
53
|
+
"through `color_map`) or `\"density\"`."),
|
|
54
|
+
f"{p}line_color": Setting(lambda v: paint(v, f"{p}line_color"),
|
|
55
|
+
doc="Outline: as the fill color."),
|
|
56
|
+
f"{p}line_width": Setting(number(lo=0), doc="Outline width (pt).", length=True),
|
|
57
|
+
f"{p}filled": Setting(either(boolean, number(0, 1)),
|
|
58
|
+
doc="`True`: filled with the color; `False`: hollow, outlined "
|
|
59
|
+
"in the color; a number: filled at that opacity and "
|
|
60
|
+
"outlined in the color."),
|
|
61
|
+
"size_basis": Setting(one_of("area", "box"),
|
|
62
|
+
doc="`area`: shapes of the same size have the same area; "
|
|
63
|
+
"`box`: size is the width of the shape's box."),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def permuted(v: Any, idx: Any) -> Any:
|
|
68
|
+
"""A per-point column (list, ``array('d')``, ``(n, 4)`` buffer) reordered by ``idx``
|
|
69
|
+
(gathered in C by pdf_io for numeric columns); single values unchanged."""
|
|
70
|
+
from pdf_io import arrays as _arrays
|
|
71
|
+
if isinstance(v, list):
|
|
72
|
+
return [v[i] for i in idx]
|
|
73
|
+
if isinstance(v, array.array):
|
|
74
|
+
n = len(idx)
|
|
75
|
+
if len(v) == 4 * n and n != 4: # flat RGBA
|
|
76
|
+
return _arrays.take(memoryview(v).cast("B").cast("d", (len(v) // 4, 4)), idx)
|
|
77
|
+
return _arrays.take(v, idx)
|
|
78
|
+
if isinstance(v, memoryview) and v.ndim == 2:
|
|
79
|
+
m: Any = v
|
|
80
|
+
k = m.shape[1]
|
|
81
|
+
out_mv: Any = memoryview(_arrays.take(m, idx)).cast("B")
|
|
82
|
+
return out_mv.cast("d", (len(idx), k))
|
|
83
|
+
return v
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def order_index(series: Any, n: int) -> Any:
|
|
87
|
+
"""The drawing order of a series' ``order`` setting, or ``None`` (call order)."""
|
|
88
|
+
from ..density import Density
|
|
89
|
+
o = series.get("order")
|
|
90
|
+
if o is None:
|
|
91
|
+
return None
|
|
92
|
+
descending = False
|
|
93
|
+
if isinstance(o, str):
|
|
94
|
+
descending = o.startswith("-")
|
|
95
|
+
dens = next((series.get(k) for k in ("color", "size", "alpha")
|
|
96
|
+
if k in series.SETTINGS and isinstance(series.get(k), Density)), None)
|
|
97
|
+
o = dens if dens is not None else series.cached("order_density", Density)
|
|
98
|
+
if isinstance(o, Density):
|
|
99
|
+
keys = series.point_values(o)
|
|
100
|
+
elif isinstance(o, Values):
|
|
101
|
+
keys = o.data
|
|
102
|
+
else:
|
|
103
|
+
keys = o
|
|
104
|
+
if len(keys) != n:
|
|
105
|
+
raise ValueError(f"{len(keys)} order keys for {n} points")
|
|
106
|
+
return _backend.argsort(keys, descending=descending)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def order_value(v: Any) -> Any:
|
|
110
|
+
"""``order``: ``"density"``, ``"-density"``, a ``Density`` or sort keys per point."""
|
|
111
|
+
from ..density import Density
|
|
112
|
+
if isinstance(v, Density):
|
|
113
|
+
return v
|
|
114
|
+
if isinstance(v, str):
|
|
115
|
+
if v.strip().lower() in ("density", "-density"):
|
|
116
|
+
return v.strip().lower()
|
|
117
|
+
raise ValueError(f"expected 'density', '-density' or sort keys, got {v!r}")
|
|
118
|
+
return _backend.doubles(v.to_numpy() if hasattr(v, "to_numpy") else v)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def alpha_value(v: Any) -> Any:
|
|
122
|
+
"""``alpha``: a number in [0, 1], or ``"density"`` / a ``Density``."""
|
|
123
|
+
from ..density import Density
|
|
124
|
+
if isinstance(v, Density):
|
|
125
|
+
return v
|
|
126
|
+
if isinstance(v, str) and v.strip().lower() == "density":
|
|
127
|
+
return Density()
|
|
128
|
+
return number(0, 1)(v)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def map_colors(cmap: ColorMap, values: Any, limits: tuple[float, ...]) -> Any:
|
|
132
|
+
"""Colors of ``values`` through a map with limits ``(flo, fhi, …)`` (after its scale
|
|
133
|
+
function)."""
|
|
134
|
+
from ..scales import get_scale
|
|
135
|
+
flo, fhi = limits[0], limits[1]
|
|
136
|
+
span = fhi - flo
|
|
137
|
+
k = 1.0 / span if span else 0.0
|
|
138
|
+
fn = get_scale(cmap.scale).function
|
|
139
|
+
ts = _backend.scaled(values, k, -flo * k, fn)
|
|
140
|
+
return cmap.colors_at(ts)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _faded(paint: Any, alpha: float) -> Any:
|
|
144
|
+
"""A resolved paint (a color, a list of colors or flat RGBA values) at ``alpha``."""
|
|
145
|
+
if isinstance(paint, array.array):
|
|
146
|
+
out = array.array("d", paint)
|
|
147
|
+
out[3::4] = _backend.scaled(out[3::4], alpha)
|
|
148
|
+
return out
|
|
149
|
+
if isinstance(paint, list):
|
|
150
|
+
return [with_alpha(c, alpha) if c is not None else None for c in paint]
|
|
151
|
+
if isinstance(paint, memoryview): # (n, 4) colors from a ColorMap
|
|
152
|
+
flat = _faded(array.array("d", paint.cast("B").cast("d")), alpha)
|
|
153
|
+
return memoryview(flat).cast("B").cast("d", (len(flat) // 4, 4))
|
|
154
|
+
return with_alpha(paint, alpha)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class MarkerStyle:
|
|
158
|
+
"""Resolved marker arguments for ``Canvas.draw_markers``."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, series: Any, ctx: Any, prefix: str = "", default_color: Any = None,
|
|
161
|
+
n: int = 0, glyph: bool = False, order: Any = None) -> None:
|
|
162
|
+
self.glyph_only = glyph
|
|
163
|
+
self.order = None if glyph else order
|
|
164
|
+
g = series.get
|
|
165
|
+
p = prefix
|
|
166
|
+
size_name = "size" if not p else "marker_size"
|
|
167
|
+
color_name = "color" if not p else "marker_color"
|
|
168
|
+
alpha = g("alpha")
|
|
169
|
+
if is_computed(alpha) and glyph:
|
|
170
|
+
alpha = series.get("alpha_range")[1]
|
|
171
|
+
elif is_computed(alpha):
|
|
172
|
+
lo, hi = series.get("alpha_range")
|
|
173
|
+
vals = self._take(series.point_values(alpha))
|
|
174
|
+
_, vmin, vmax = _backend.extent(vals, None)
|
|
175
|
+
span = (vmax - vmin) if vmax > vmin else 1.0
|
|
176
|
+
k = (hi - lo) / span
|
|
177
|
+
alpha = _backend.scaled(vals, k, lo - k * vmin, None, nan=0.0)
|
|
178
|
+
self.opacity = 1.0 if alpha is None else alpha
|
|
179
|
+
marker = g("marker")
|
|
180
|
+
basis = g("size_basis")
|
|
181
|
+
size = g(size_name)
|
|
182
|
+
if isinstance(marker, list):
|
|
183
|
+
marker = self._take(marker)
|
|
184
|
+
if isinstance(size, array.array):
|
|
185
|
+
size = self._take(size)
|
|
186
|
+
if isinstance(marker, list) and glyph:
|
|
187
|
+
marker = marker[0]
|
|
188
|
+
if isinstance(marker, list):
|
|
189
|
+
if len(marker) != n:
|
|
190
|
+
raise ValueError(f"{len(marker)} markers for {n} points")
|
|
191
|
+
resolved = [resolve(m) for m in marker]
|
|
192
|
+
self.marker: Any = [r[0] for r in resolved]
|
|
193
|
+
factor: Any = array.array("d", [area_factor(m) if basis == "area" else 1.0
|
|
194
|
+
for m in marker])
|
|
195
|
+
else:
|
|
196
|
+
self.marker = resolve(marker)[0]
|
|
197
|
+
factor = area_factor(marker) if basis == "area" else 1.0
|
|
198
|
+
smap = series.size_map_obj(size_name) if hasattr(series, "size_map_obj") else None
|
|
199
|
+
if smap is not None and glyph:
|
|
200
|
+
size = (smap.sizes[0] + smap.sizes[1]) / 2
|
|
201
|
+
elif smap is not None:
|
|
202
|
+
from ..scales import get_scale
|
|
203
|
+
values = self._take(series.point_values(size))
|
|
204
|
+
flo, fhi = ctx.maps[id(smap)][:2]
|
|
205
|
+
size = smap.map(values, flo, fhi, get_scale(smap.scale).function)
|
|
206
|
+
if isinstance(size, array.array) and glyph:
|
|
207
|
+
finite = sorted(v for v in size[::max(1, len(size) // 1000)] if v == v)
|
|
208
|
+
size = finite[len(finite) // 2] if finite else 4.0
|
|
209
|
+
if isinstance(size, array.array):
|
|
210
|
+
if len(size) != n:
|
|
211
|
+
raise ValueError(f"{len(size)} sizes for {n} points")
|
|
212
|
+
if isinstance(factor, array.array):
|
|
213
|
+
size = array.array("d", [s * f for s, f in zip(size, factor)])
|
|
214
|
+
elif factor != 1.0:
|
|
215
|
+
size = _backend.scaled(size, factor)
|
|
216
|
+
elif isinstance(factor, array.array):
|
|
217
|
+
size = array.array("d", [size * f for f in factor])
|
|
218
|
+
else:
|
|
219
|
+
size = size * factor
|
|
220
|
+
self.size = size
|
|
221
|
+
fill = g(color_name)
|
|
222
|
+
if fill is None:
|
|
223
|
+
fill = default_color
|
|
224
|
+
self.fill = self._paint(fill, series, ctx, n)
|
|
225
|
+
self.stroke = self._paint(g(f"{p}line_color"), series, ctx, n)
|
|
226
|
+
self.line_width = g(f"{p}line_width")
|
|
227
|
+
filled = g(f"{p}filled")
|
|
228
|
+
if filled is not True and self.fill is not None:
|
|
229
|
+
if self.stroke is None:
|
|
230
|
+
self.stroke = self.fill
|
|
231
|
+
self.fill = None if filled is False else _faded(self.fill, filled)
|
|
232
|
+
if self.stroke is None:
|
|
233
|
+
self.line_width = None
|
|
234
|
+
|
|
235
|
+
def _paint(self, value: Any, series: Any, ctx: Any, n: int) -> Any:
|
|
236
|
+
if value is None:
|
|
237
|
+
return None
|
|
238
|
+
mapped = isinstance(value, Values) or is_computed(value)
|
|
239
|
+
if mapped and self.glyph_only:
|
|
240
|
+
return series.color_map_obj()(0.5)
|
|
241
|
+
if isinstance(value, list) and self.glyph_only:
|
|
242
|
+
return resolve_color(next((c for c in value if c is not None), None), ctx.palette)
|
|
243
|
+
if isinstance(value, memoryview) and self.glyph_only:
|
|
244
|
+
return tuple(value[0, k] for k in range(4)) if len(value) else None
|
|
245
|
+
if mapped:
|
|
246
|
+
values = self._take(series.point_values(value))
|
|
247
|
+
if len(values) != n:
|
|
248
|
+
raise ValueError(f"{len(values)} color values for {n} points")
|
|
249
|
+
cmap = series.color_map_obj()
|
|
250
|
+
return map_colors(cmap, values, ctx.maps[id(cmap)])
|
|
251
|
+
if isinstance(value, memoryview):
|
|
252
|
+
if len(value) != n:
|
|
253
|
+
raise ValueError(f"{len(value)} colors for {n} points")
|
|
254
|
+
return self._take(value)
|
|
255
|
+
if isinstance(value, list):
|
|
256
|
+
if len(value) != n:
|
|
257
|
+
raise ValueError(f"{len(value)} colors for {n} points")
|
|
258
|
+
return [resolve_color(c, ctx.palette) for c in self._take(value)]
|
|
259
|
+
return resolve_color(value, ctx.palette)
|
|
260
|
+
|
|
261
|
+
def first_color(self, which: str = "fill") -> Any:
|
|
262
|
+
v = self.fill if which == "fill" else self.stroke
|
|
263
|
+
if v is None or isinstance(v, (str, tuple)):
|
|
264
|
+
return v
|
|
265
|
+
if isinstance(v, list):
|
|
266
|
+
return next((c for c in v if c is not None), None)
|
|
267
|
+
if isinstance(v, array.array): # flat RGBA
|
|
268
|
+
return tuple(v[:4]) if len(v) >= 4 else None
|
|
269
|
+
if isinstance(v, memoryview) and v.ndim == 2:
|
|
270
|
+
m: Any = v
|
|
271
|
+
return tuple(m[0, k] for k in range(m.shape[1])) if len(m) else None
|
|
272
|
+
try:
|
|
273
|
+
return tuple(v[0])
|
|
274
|
+
except (TypeError, IndexError):
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
def _take(self, v: Any) -> Any:
|
|
278
|
+
"""A per-point input in drawing order (``order``), before colors are mapped."""
|
|
279
|
+
return v if self.order is None or v is None else permuted(v, self.order)
|
|
280
|
+
|
|
281
|
+
def draw(self, canvas: Any, xs: Any, ys: Any, simplify: Any) -> None:
|
|
282
|
+
if self.fill is None and self.stroke is None:
|
|
283
|
+
return
|
|
284
|
+
canvas.draw_markers(xs, ys, marker=self.marker, size=self.size, fill=self.fill,
|
|
285
|
+
stroke=self.stroke, line_width=self.line_width,
|
|
286
|
+
opacity=self.opacity, simplify=simplify)
|
|
287
|
+
|
|
288
|
+
def glyph(self, canvas: Any, cx: float, cy: float, max_size: float) -> None:
|
|
289
|
+
"""One marker for a legend entry."""
|
|
290
|
+
size = self.size
|
|
291
|
+
if not isinstance(size, (int, float)):
|
|
292
|
+
finite = [s for s in size if s == s]
|
|
293
|
+
size = sorted(finite)[len(finite) // 2] if finite else 4.0
|
|
294
|
+
size = min(size, max_size)
|
|
295
|
+
marker = self.marker[0] if isinstance(self.marker, list) else self.marker
|
|
296
|
+
fill = self.first_color("fill")
|
|
297
|
+
stroke = self.first_color("stroke")
|
|
298
|
+
if fill is None and stroke is None:
|
|
299
|
+
return
|
|
300
|
+
canvas.draw_markers([cx], [cy], marker=marker, size=size, fill=fill, stroke=stroke,
|
|
301
|
+
line_width=self.line_width, opacity=self.opacity)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def order_settings() -> dict[str, Setting]:
|
|
305
|
+
"""``order``, ``alpha`` (numbers or a density) and ``alpha_range``."""
|
|
306
|
+
from ..settings import pair
|
|
307
|
+
return {
|
|
308
|
+
"order": Setting(order_value,
|
|
309
|
+
doc="Drawing order: `\"density\"` (densest drawn last), "
|
|
310
|
+
"`\"-density\"`, or sort keys per point (ascending); default: "
|
|
311
|
+
"call order."),
|
|
312
|
+
"alpha": Setting(alpha_value, inherited=True,
|
|
313
|
+
doc="Opacity, multiplying the colors' own; or `\"density\"` / a "
|
|
314
|
+
"`Density`, mapped linearly to `alpha_range`."),
|
|
315
|
+
"alpha_range": Setting(pair(number(0, 1)),
|
|
316
|
+
doc="Opacities of the least and most dense points when "
|
|
317
|
+
"`alpha` is a density."),
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def density_points(series: Any, xs: Any, ys: Any) -> tuple[Any, Any]:
|
|
322
|
+
"""Points ``(xs, ys)`` on the plot's axis positions (log axes in log space)."""
|
|
323
|
+
from ..scales import get_scale
|
|
324
|
+
out = []
|
|
325
|
+
for v, axis in ((xs, series.plot.x_axis), (ys, series.plot.y_axis)):
|
|
326
|
+
name = axis.get("scale")
|
|
327
|
+
fn = get_scale(name, axis.get("linthresh")).function \
|
|
328
|
+
if name not in (None, "category", "time") else None
|
|
329
|
+
out.append(_backend.scaled(v, 1.0, 0.0, fn) if fn else _backend.doubles(v))
|
|
330
|
+
return out[0], out[1]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def density_stats(series: Any) -> dict[str, Any]:
|
|
334
|
+
"""``{"density": values}`` when a setting of the series maps a density."""
|
|
335
|
+
from ..density import Density
|
|
336
|
+
for k in ("color", "line_color", "size", "alpha", "order"):
|
|
337
|
+
if k in series.SETTINGS and isinstance(series.get(k), Density):
|
|
338
|
+
return {"density": series.point_values(series.get(k))}
|
|
339
|
+
if "order" in series.SETTINGS and isinstance(series.get("order"), str):
|
|
340
|
+
return {"density": series.point_values(series.cached("order_density", Density))}
|
|
341
|
+
return {}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Regression: a fitted line (polynomial or LOWESS) with a confidence band."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .. import _backend, stats
|
|
8
|
+
from ..settings import Setting, boolean, color, either, literal, number, table
|
|
9
|
+
from ..style import dash_of, with_alpha
|
|
10
|
+
from . import Series, common
|
|
11
|
+
from .points import MarkerStyle, marker_settings
|
|
12
|
+
|
|
13
|
+
__all__ = ["Regression", "t_quantile"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _solve(a: list[list[float]], b: list[float]) -> list[float]:
|
|
17
|
+
"""Gaussian elimination with partial pivoting."""
|
|
18
|
+
n = len(b)
|
|
19
|
+
m = [row[:] + [bv] for row, bv in zip(a, b)]
|
|
20
|
+
for i in range(n):
|
|
21
|
+
p = max(range(i, n), key=lambda r: abs(m[r][i]))
|
|
22
|
+
if abs(m[p][i]) < 1e-300:
|
|
23
|
+
raise ValueError("the fit is singular (too few distinct x values for the order)")
|
|
24
|
+
m[i], m[p] = m[p], m[i]
|
|
25
|
+
for r in range(i + 1, n):
|
|
26
|
+
f = m[r][i] / m[i][i]
|
|
27
|
+
for c in range(i, n + 1):
|
|
28
|
+
m[r][c] -= f * m[i][c]
|
|
29
|
+
x = [0.0] * n
|
|
30
|
+
for i in range(n - 1, -1, -1):
|
|
31
|
+
x[i] = (m[i][n] - sum(m[i][c] * x[c] for c in range(i + 1, n))) / m[i][i]
|
|
32
|
+
return x
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _inverse(a: list[list[float]]) -> list[list[float]]:
|
|
36
|
+
n = len(a)
|
|
37
|
+
cols = [_solve(a, [1.0 if r == c else 0.0 for r in range(n)]) for c in range(n)]
|
|
38
|
+
return [[cols[c][r] for c in range(n)] for r in range(n)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _betacf(a: float, b: float, x: float) -> float:
|
|
42
|
+
qab, qap, qam = a + b, a + 1.0, a - 1.0
|
|
43
|
+
c, d = 1.0, 1.0 - qab * x / qap
|
|
44
|
+
d = 1.0 / (d if abs(d) > 1e-300 else 1e-300)
|
|
45
|
+
h = d
|
|
46
|
+
for m in range(1, 300):
|
|
47
|
+
m2 = 2 * m
|
|
48
|
+
aa = m * (b - m) * x / ((qam + m2) * (a + m2))
|
|
49
|
+
d = 1.0 + aa * d
|
|
50
|
+
d = 1.0 / (d if abs(d) > 1e-300 else 1e-300)
|
|
51
|
+
c = 1.0 + aa / c if abs(c) > 1e-300 else 1e300
|
|
52
|
+
h *= d * c
|
|
53
|
+
aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
|
|
54
|
+
d = 1.0 + aa * d
|
|
55
|
+
d = 1.0 / (d if abs(d) > 1e-300 else 1e-300)
|
|
56
|
+
c = 1.0 + aa / c if abs(c) > 1e-300 else 1e300
|
|
57
|
+
de = d * c
|
|
58
|
+
h *= de
|
|
59
|
+
if abs(de - 1.0) < 1e-15:
|
|
60
|
+
break
|
|
61
|
+
return h
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _betai(a: float, b: float, x: float) -> float:
|
|
65
|
+
if x <= 0:
|
|
66
|
+
return 0.0
|
|
67
|
+
if x >= 1:
|
|
68
|
+
return 1.0
|
|
69
|
+
lb = math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b) + a * math.log(x) \
|
|
70
|
+
+ b * math.log(1 - x)
|
|
71
|
+
if x < (a + 1) / (a + b + 2):
|
|
72
|
+
return math.exp(lb) * _betacf(a, b, x) / a
|
|
73
|
+
return 1.0 - math.exp(lb) * _betacf(b, a, 1 - x) / b
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _t_cdf(t: float, df: float) -> float:
|
|
77
|
+
x = df / (df + t * t)
|
|
78
|
+
p = 0.5 * _betai(df / 2.0, 0.5, x)
|
|
79
|
+
return 1.0 - p if t > 0 else p
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def t_quantile(p: float, df: float) -> float:
|
|
83
|
+
"""The ``p`` quantile of Student's t distribution with ``df`` degrees of freedom."""
|
|
84
|
+
if df <= 0:
|
|
85
|
+
return math.inf
|
|
86
|
+
lo, hi = -1e3, 1e3
|
|
87
|
+
for _ in range(200):
|
|
88
|
+
mid = (lo + hi) / 2
|
|
89
|
+
if _t_cdf(mid, df) < p:
|
|
90
|
+
lo = mid
|
|
91
|
+
else:
|
|
92
|
+
hi = mid
|
|
93
|
+
return (lo + hi) / 2
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Regression(Series):
|
|
97
|
+
"""A regression of ``y`` on ``x``: a polynomial of ``order`` (least squares) with a
|
|
98
|
+
confidence band of the mean, or a LOWESS curve; ``scatter`` also draws the points."""
|
|
99
|
+
|
|
100
|
+
SETTINGS = table(
|
|
101
|
+
order=Setting(number(lo=1, hi=5, integer=True), doc="Polynomial order (1–5)."),
|
|
102
|
+
lowess=Setting(boolean, doc="Fit a LOWESS curve instead (no band)."),
|
|
103
|
+
frac=Setting(number(0, 1), doc="Fraction of the points in each LOWESS fit."),
|
|
104
|
+
ci=Setting(either(literal(None), number(0, 100)),
|
|
105
|
+
doc="Confidence level of the band (%); `None`: no band."),
|
|
106
|
+
logx=Setting(boolean, doc="Fit y against log(x) (x > 0)."),
|
|
107
|
+
scatter=Setting(boolean, doc="Draw the points too."),
|
|
108
|
+
truncate=Setting(boolean, doc="Draw the fit over the data range only (`False`: "
|
|
109
|
+
"over the axis range)."),
|
|
110
|
+
points=Setting(number(lo=2, integer=True), doc="Points of the fitted curve."),
|
|
111
|
+
color=Setting(color, doc="Line, band and point color; default the next palette color."),
|
|
112
|
+
line_width=Setting(number(lo=0), doc="Line width (pt).", length=True),
|
|
113
|
+
line_style=Setting(lambda v: v, doc="Line style."),
|
|
114
|
+
band_alpha=Setting(number(0, 1), doc="Opacity of the band."),
|
|
115
|
+
**{**marker_settings("marker_")},
|
|
116
|
+
**common("name", "alpha", "z", "rasterize", "simplify", "font_size", "font_family",
|
|
117
|
+
"text_color"),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def __init__(self, x: Any, y: Any, plot: Any = None, **settings: Any) -> None:
|
|
121
|
+
self.x, self.y = _backend.doubles(x), _backend.doubles(y)
|
|
122
|
+
if len(self.x) != len(self.y):
|
|
123
|
+
raise ValueError(f"x has {len(self.x)} values and y {len(self.y)}")
|
|
124
|
+
super().__init__(plot=plot, **settings)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def stats(self) -> dict[str, Any]:
|
|
128
|
+
"""``coefficients`` (highest power last, in x or log x), ``r2``, ``se`` (residual
|
|
129
|
+
standard error), and the curve: ``x``, ``y``, ``low``, ``high``."""
|
|
130
|
+
return self.cached("stats", self._compute)
|
|
131
|
+
|
|
132
|
+
def _pairs(self) -> list[tuple[float, float]]:
|
|
133
|
+
logx = self.get("logx")
|
|
134
|
+
return [(math.log(a) if logx else a, b) for a, b in zip(self.x, self.y)
|
|
135
|
+
if math.isfinite(a) and math.isfinite(b) and (a > 0 or not logx)]
|
|
136
|
+
|
|
137
|
+
def _compute(self) -> dict[str, Any]:
|
|
138
|
+
logx = self.get("logx")
|
|
139
|
+
pts = self._pairs()
|
|
140
|
+
n = len(pts)
|
|
141
|
+
npts = self.get("points")
|
|
142
|
+
if n < 2:
|
|
143
|
+
return {"coefficients": [], "r2": math.nan, "se": math.nan, "x": array.array("d"),
|
|
144
|
+
"y": array.array("d"), "low": None, "high": None}
|
|
145
|
+
lo = min(p[0] for p in pts)
|
|
146
|
+
hi = max(p[0] for p in pts)
|
|
147
|
+
grid = [lo + (hi - lo) * i / (npts - 1) for i in range(npts)]
|
|
148
|
+
if self.get("lowess"):
|
|
149
|
+
xs, fit = stats.lowess([p[0] for p in pts], [p[1] for p in pts], self.get("frac"))
|
|
150
|
+
gx = array.array("d", (math.exp(v) for v in xs) if logx else xs)
|
|
151
|
+
return {"coefficients": [], "r2": math.nan, "se": math.nan, "x": gx, "y": fit,
|
|
152
|
+
"low": None, "high": None}
|
|
153
|
+
k = min(self.get("order"), n - 1) + 1
|
|
154
|
+
mx = math.fsum(p[0] for p in pts) / n
|
|
155
|
+
sx = math.sqrt(math.fsum((p[0] - mx) ** 2 for p in pts) / n) or 1.0
|
|
156
|
+
|
|
157
|
+
def row(v: float) -> list[float]:
|
|
158
|
+
u = (v - mx) / sx
|
|
159
|
+
return [u ** j for j in range(k)]
|
|
160
|
+
|
|
161
|
+
rows = [row(p[0]) for p in pts]
|
|
162
|
+
ata = [[math.fsum(r[i] * r[j] for r in rows) for j in range(k)] for i in range(k)]
|
|
163
|
+
aty = [math.fsum(r[i] * p[1] for r, p in zip(rows, pts)) for i in range(k)]
|
|
164
|
+
beta = _solve(ata, aty)
|
|
165
|
+
fitted = [sum(b * v for b, v in zip(beta, r)) for r in rows]
|
|
166
|
+
my = math.fsum(p[1] for p in pts) / n
|
|
167
|
+
ss_res = math.fsum((p[1] - f) ** 2 for p, f in zip(pts, fitted))
|
|
168
|
+
ss_tot = math.fsum((p[1] - my) ** 2 for p in pts)
|
|
169
|
+
dof = n - k
|
|
170
|
+
se = math.sqrt(ss_res / dof) if dof > 0 else math.nan
|
|
171
|
+
gy = [sum(b * v for b, v in zip(beta, row(g))) for g in grid]
|
|
172
|
+
low = high = None
|
|
173
|
+
ci = self.get("ci")
|
|
174
|
+
if ci is not None and dof > 0:
|
|
175
|
+
inv = _inverse(ata)
|
|
176
|
+
t = t_quantile(0.5 + ci / 200.0, dof)
|
|
177
|
+
low, high = array.array("d"), array.array("d")
|
|
178
|
+
for g, yv in zip(grid, gy):
|
|
179
|
+
r = row(g)
|
|
180
|
+
var = sum(r[i] * inv[i][j] * r[j] for i in range(k) for j in range(k))
|
|
181
|
+
h = t * se * math.sqrt(max(var, 0.0))
|
|
182
|
+
low.append(yv - h)
|
|
183
|
+
high.append(yv + h)
|
|
184
|
+
# coefficients in powers of x (or log x)
|
|
185
|
+
coef = [0.0] * k
|
|
186
|
+
for j, b in enumerate(beta):
|
|
187
|
+
for i in range(j + 1):
|
|
188
|
+
coef[i] += b * math.comb(j, i) * (-mx) ** (j - i) / sx ** j
|
|
189
|
+
gx = array.array("d", (math.exp(v) for v in grid) if logx else grid)
|
|
190
|
+
return {"coefficients": coef, "r2": 1.0 - ss_res / ss_tot if ss_tot else math.nan,
|
|
191
|
+
"se": se, "x": gx, "y": array.array("d", gy), "low": low, "high": high}
|
|
192
|
+
|
|
193
|
+
def values(self, role: str) -> Any:
|
|
194
|
+
st = self.stats
|
|
195
|
+
out = array.array("d", self.x if role == "x" else self.y)
|
|
196
|
+
if role == "y":
|
|
197
|
+
for v in (st["low"], st["high"]):
|
|
198
|
+
if v is not None:
|
|
199
|
+
out.extend(v)
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
def occupy(self, ctx: Any, occ: Any) -> None:
|
|
203
|
+
st = self.stats
|
|
204
|
+
if self.get("scatter"):
|
|
205
|
+
from ..occupancy import thin
|
|
206
|
+
x, y = thin(self.x, self.y)
|
|
207
|
+
occ.points(ctx.xmap.many(x), ctx.ymap.many(y))
|
|
208
|
+
if len(st["x"]):
|
|
209
|
+
px = ctx.xmap.many(st["x"])
|
|
210
|
+
occ.lines(px, ctx.ymap.many(st["y"]))
|
|
211
|
+
if st["low"] is not None:
|
|
212
|
+
occ.band(px, ctx.ymap.many(st["low"]), ctx.ymap.many(st["high"]))
|
|
213
|
+
|
|
214
|
+
def legend_points(self) -> tuple[Any, Any]:
|
|
215
|
+
return self.x, self.y
|
|
216
|
+
|
|
217
|
+
def _color(self, ctx: Any) -> Any:
|
|
218
|
+
return with_alpha(self.base_color(ctx), self.get("alpha"))
|
|
219
|
+
|
|
220
|
+
def draw(self, ctx: Any) -> None:
|
|
221
|
+
st = self.stats
|
|
222
|
+
c = self._color(ctx)
|
|
223
|
+
canvas = ctx.canvas
|
|
224
|
+
if self.get("scatter"):
|
|
225
|
+
style = MarkerStyle(self, ctx, "marker_", c, len(self.x))
|
|
226
|
+
style.draw(canvas, ctx.xmap.many(self.x), ctx.ymap.many(self.y), self.simplify_arg())
|
|
227
|
+
if c is None or not len(st["x"]):
|
|
228
|
+
return
|
|
229
|
+
px = ctx.xmap.many(st["x"])
|
|
230
|
+
if st["low"] is not None:
|
|
231
|
+
canvas.fill_between(px, ctx.ymap.many(st["low"]), ctx.ymap.many(st["high"]),
|
|
232
|
+
color=c, opacity=self.get("band_alpha"))
|
|
233
|
+
lw = self.get("line_width")
|
|
234
|
+
if lw:
|
|
235
|
+
canvas.draw_lines(px, ctx.ymap.many(st["y"]), color=c, width=lw,
|
|
236
|
+
dash=dash_of(self.get("line_style"), lw))
|
|
237
|
+
|
|
238
|
+
def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
|
|
239
|
+
return None, self._color(ctx)
|
|
240
|
+
|
|
241
|
+
def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
|
|
242
|
+
c = self._color(ctx)
|
|
243
|
+
if c is None:
|
|
244
|
+
return
|
|
245
|
+
canvas = ctx.canvas
|
|
246
|
+
if self.get("ci") is not None and not self.get("lowess"):
|
|
247
|
+
canvas.fill_style = with_alpha(c, self.get("band_alpha"))
|
|
248
|
+
canvas.fill_rect(x, y + h * 0.2, w, h * 0.6)
|
|
249
|
+
canvas.draw_lines([x, x + w], [y + h / 2, y + h / 2], color=c,
|
|
250
|
+
width=min(self.get("line_width"), h / 3))
|