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/hist2d.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""2-D histograms: counts of points in rectangular bins, drawn as a heatmap."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .. import _backend, stats
|
|
8
|
+
from ..scales import get_scale
|
|
9
|
+
from ..settings import Setting, either, literal, number, one_of, pair, table
|
|
10
|
+
from .heatmap import Heatmap, _Coords
|
|
11
|
+
|
|
12
|
+
__all__ = ["Hist2d"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _bins2(v: Any) -> Any:
|
|
16
|
+
if isinstance(v, (tuple, list)) and len(v) == 2 and not all(
|
|
17
|
+
isinstance(x, (int, float)) for x in v):
|
|
18
|
+
return (stats.check_bins(v[0]), stats.check_bins(v[1]))
|
|
19
|
+
if isinstance(v, (tuple, list)) and len(v) == 2 and all(
|
|
20
|
+
isinstance(x, int) and not isinstance(x, bool) for x in v):
|
|
21
|
+
return (stats.check_bins(v[0]), stats.check_bins(v[1]))
|
|
22
|
+
b = stats.check_bins(v)
|
|
23
|
+
return (b, b)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _range2(v: Any) -> Any:
|
|
27
|
+
return (pair(number())(v[0]), pair(number())(v[1]))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Hist2d(Heatmap):
|
|
31
|
+
"""A 2-D histogram of points ``(x, y)``: counts in bins (even on the axis scales),
|
|
32
|
+
drawn as a heatmap; bins with fewer than ``min_count`` points are left empty."""
|
|
33
|
+
|
|
34
|
+
SETTINGS = table(
|
|
35
|
+
**{k: v for k, v in Heatmap.SETTINGS.items() if k not in ("origin",)},
|
|
36
|
+
bins=Setting(_bins2,
|
|
37
|
+
doc="Bins of both axes, or `(x bins, y bins)`: counts, edges or rules "
|
|
38
|
+
"(as `Hist`)."),
|
|
39
|
+
range=Setting(either(literal(None), _range2),
|
|
40
|
+
doc="`((x0, x1), (y0, y1))`; default the data range."),
|
|
41
|
+
weights=Setting(lambda v: _backend.doubles(v), doc="A weight per point."),
|
|
42
|
+
stat=Setting(one_of("count", "density", "probability", "percent"),
|
|
43
|
+
doc="`count`, `density` (volume 1), `probability` or `percent`."),
|
|
44
|
+
min_count=Setting(number(lo=0), doc="Bins below this count are transparent."),
|
|
45
|
+
origin=Setting(one_of("top", "bottom"), doc="Row of the first y bin."),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def __init__(self, x: Any, y: Any, plot: Any = None, **settings: Any) -> None:
|
|
49
|
+
self.px, self.py = _backend.doubles(x), _backend.doubles(y)
|
|
50
|
+
if len(self.px) != len(self.py):
|
|
51
|
+
raise ValueError(f"x has {len(self.px)} values and y {len(self.py)}")
|
|
52
|
+
self.k = 1
|
|
53
|
+
self._map: Any = None
|
|
54
|
+
self._map_key: Any = None
|
|
55
|
+
self._bar: Any = None
|
|
56
|
+
from . import Series
|
|
57
|
+
Series.__init__(self, plot=plot, **settings)
|
|
58
|
+
if self.get("colorbar"):
|
|
59
|
+
self._attach_bar()
|
|
60
|
+
|
|
61
|
+
def inverts(self, role: str) -> bool:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
def _scales(self) -> tuple[Any, Any]:
|
|
65
|
+
out = []
|
|
66
|
+
for axis in (self.plot.x_axis, self.plot.y_axis):
|
|
67
|
+
name = axis.nominal_scale
|
|
68
|
+
out.append(get_scale("linear" if name == "category" else name))
|
|
69
|
+
return out[0], out[1]
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def stats(self) -> dict[str, Any]:
|
|
73
|
+
"""``{"x_edges", "y_edges", "counts": rows (first y bin first), "values": rows}``."""
|
|
74
|
+
sx, sy = self._scales()
|
|
75
|
+
return self.cached(("stats", sx.name, sy.name), lambda: self._compute(sx, sy))
|
|
76
|
+
|
|
77
|
+
def _compute(self, sx: Any, sy: Any) -> dict[str, Any]:
|
|
78
|
+
bx, by = self.get("bins")
|
|
79
|
+
rng = self.get("range")
|
|
80
|
+
fx = _backend.scaled(self.px, 1.0, 0.0, sx.function) if sx.function else self.px
|
|
81
|
+
fy = _backend.scaled(self.py, 1.0, 0.0, sy.function) if sy.function else self.py
|
|
82
|
+
w = self.get("weights")
|
|
83
|
+
|
|
84
|
+
def edges(b: Any, v: Any, s: Any, r: Any) -> Any:
|
|
85
|
+
if isinstance(b, array.array):
|
|
86
|
+
return array.array("d", (s.f(e) for e in b))
|
|
87
|
+
fr = None if r is None else (s.f(r[0]), s.f(r[1]))
|
|
88
|
+
return stats.bin_edges(v, b, fr)
|
|
89
|
+
|
|
90
|
+
xe = edges(bx, fx, sx, None if rng is None else rng[0])
|
|
91
|
+
ye = edges(by, fy, sy, None if rng is None else rng[1])
|
|
92
|
+
counts = stats.histogram2d(fx, fy, xe, ye, w)
|
|
93
|
+
total = math.fsum(math.fsum(r) for r in counts)
|
|
94
|
+
st = self.get("stat")
|
|
95
|
+
xd = [sx.inverse(e) for e in xe]
|
|
96
|
+
yd = [sy.inverse(e) for e in ye]
|
|
97
|
+
mc = self.get("min_count")
|
|
98
|
+
rows = []
|
|
99
|
+
for j, r in enumerate(counts):
|
|
100
|
+
out = array.array("d")
|
|
101
|
+
for i, c in enumerate(r):
|
|
102
|
+
if c < mc or c == 0:
|
|
103
|
+
out.append(math.nan)
|
|
104
|
+
elif st == "count":
|
|
105
|
+
out.append(c)
|
|
106
|
+
elif st == "probability":
|
|
107
|
+
out.append(c / total)
|
|
108
|
+
elif st == "percent":
|
|
109
|
+
out.append(100.0 * c / total)
|
|
110
|
+
else:
|
|
111
|
+
area = (xe[i + 1] - xe[i]) * (ye[j + 1] - ye[j])
|
|
112
|
+
out.append(c / (total * area) if area else math.nan)
|
|
113
|
+
rows.append(out)
|
|
114
|
+
return {"x_edges": array.array("d", xd), "y_edges": array.array("d", yd),
|
|
115
|
+
"counts": counts, "values": rows}
|
|
116
|
+
|
|
117
|
+
# the heatmap's grid, from the bins
|
|
118
|
+
@property
|
|
119
|
+
def flat(self) -> Any:
|
|
120
|
+
out = array.array("d")
|
|
121
|
+
for r in self.stats["values"]:
|
|
122
|
+
out.extend(r)
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
@flat.setter
|
|
126
|
+
def flat(self, v: Any) -> None:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def h(self) -> int:
|
|
131
|
+
return len(self.stats["values"])
|
|
132
|
+
|
|
133
|
+
@h.setter
|
|
134
|
+
def h(self, v: Any) -> None:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def w(self) -> int:
|
|
139
|
+
rows = self.stats["values"]
|
|
140
|
+
return len(rows[0]) if rows else 0
|
|
141
|
+
|
|
142
|
+
@w.setter
|
|
143
|
+
def w(self, v: Any) -> None:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def xc(self) -> _Coords:
|
|
148
|
+
return _Coords(self.stats["x_edges"], self.w, "x")
|
|
149
|
+
|
|
150
|
+
@xc.setter
|
|
151
|
+
def xc(self, v: Any) -> None:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def yc(self) -> _Coords:
|
|
156
|
+
return _Coords(self.stats["y_edges"], self.h, "y")
|
|
157
|
+
|
|
158
|
+
@yc.setter
|
|
159
|
+
def yc(self, v: Any) -> None:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
def legend_points(self) -> tuple[Any, Any]:
|
|
163
|
+
return self.px, self.py
|
plot_io/series/kde.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Kernel density estimates: smooth curves of the distribution of values."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .. import _backend, stats
|
|
8
|
+
from ..scales import get_scale
|
|
9
|
+
from ..settings import Setting, boolean, either, literal, number, one_of, pair, table
|
|
10
|
+
from ..shapes import fill_polygons
|
|
11
|
+
from ..stacking import check_stack, stacked
|
|
12
|
+
from ..style import dash_of, with_alpha
|
|
13
|
+
from . import Series, common
|
|
14
|
+
from .hist import _weights, value_sets
|
|
15
|
+
from .points import colors_only
|
|
16
|
+
|
|
17
|
+
__all__ = ["Kde", "check_bandwidth"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def check_bandwidth(v: Any) -> Any:
|
|
21
|
+
if isinstance(v, str):
|
|
22
|
+
return one_of("scott", "silverman")(v)
|
|
23
|
+
return number(positive=True)(v)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Kde(Series):
|
|
27
|
+
"""A Gaussian kernel density estimate of ``values`` (one array, or several as a dict or
|
|
28
|
+
a list of arrays, on a common grid)."""
|
|
29
|
+
|
|
30
|
+
SETTINGS = table(
|
|
31
|
+
bandwidth=Setting(check_bandwidth,
|
|
32
|
+
doc="`scott`, `silverman`, or a bandwidth in data units."),
|
|
33
|
+
adjust=Setting(number(positive=True), doc="Factor applied to the bandwidth."),
|
|
34
|
+
cut=Setting(number(lo=0), doc="The curve extends this many bandwidths past the "
|
|
35
|
+
"data."),
|
|
36
|
+
clip=Setting(either(literal(None), pair(number())), doc="Limits of the curve."),
|
|
37
|
+
grid=Setting(number(lo=16, integer=True), doc="Number of points of the curve."),
|
|
38
|
+
cumulative=Setting(boolean, doc="Plot the cumulative distribution."),
|
|
39
|
+
fill=Setting(boolean, doc="Fill under the curve."),
|
|
40
|
+
weights=Setting(_weights, doc="A weight per value (per set for several sets)."),
|
|
41
|
+
orientation=Setting(one_of("vertical", "horizontal"),
|
|
42
|
+
doc="`vertical` (values along x) or `horizontal`."),
|
|
43
|
+
stack=Setting(check_stack, doc="Stack the sets (seaborn's `multiple=\"stack\"`)."),
|
|
44
|
+
color=Setting(colors_only, doc="Color, or one per set; default palette colors."),
|
|
45
|
+
alpha=Setting(number(0, 1), doc="Fill opacity; default 0.25 (1 when stacked)."),
|
|
46
|
+
line_width=Setting(number(lo=0), inherited=True, doc="Line width (pt).", length=True),
|
|
47
|
+
line_style=Setting(one_of("solid", "dashed", "dotted", "dashdot"),
|
|
48
|
+
doc="Line style."),
|
|
49
|
+
**common("name", "z", "rasterize", "simplify", "font_size", "font_family", "text_color"),
|
|
50
|
+
)
|
|
51
|
+
STACK_GROUP = "kde"
|
|
52
|
+
|
|
53
|
+
def __init__(self, values: Any, plot: Any = None, **settings: Any) -> None:
|
|
54
|
+
self.sets, self.named = value_sets(values)
|
|
55
|
+
super().__init__(plot=plot, **settings)
|
|
56
|
+
|
|
57
|
+
def _horizontal(self) -> bool:
|
|
58
|
+
return self.get("orientation") == "horizontal"
|
|
59
|
+
|
|
60
|
+
def _val_role(self) -> str:
|
|
61
|
+
return "y" if self._horizontal() else "x"
|
|
62
|
+
|
|
63
|
+
def _scale(self) -> Any:
|
|
64
|
+
axis = self.plot.y_axis if self._horizontal() else self.plot.x_axis
|
|
65
|
+
name = axis.nominal_scale
|
|
66
|
+
return get_scale("linear" if name == "category" else name)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def stats(self) -> dict[str, Any]:
|
|
70
|
+
"""``{"grid": values, "densities": [per set], "bandwidths": [per set]}`` (densities
|
|
71
|
+
per unit of the axis scale on log axes)."""
|
|
72
|
+
scale = self._scale()
|
|
73
|
+
return self.cached(("stats", scale.name), lambda: self._compute(scale))
|
|
74
|
+
|
|
75
|
+
def _compute(self, scale: Any) -> dict[str, Any]:
|
|
76
|
+
fn = scale.function
|
|
77
|
+
weights = self.get("weights")
|
|
78
|
+
fsets, ws, bws = [], [], []
|
|
79
|
+
for i, (key, v) in enumerate(self.sets):
|
|
80
|
+
fv = _backend.scaled(v, 1.0, 0.0, fn) if fn else v
|
|
81
|
+
w = None
|
|
82
|
+
if weights is not None:
|
|
83
|
+
w = (weights[key] if isinstance(weights, dict) else
|
|
84
|
+
weights[i] if isinstance(weights, list) else weights)
|
|
85
|
+
fsets.append(fv)
|
|
86
|
+
ws.append(w)
|
|
87
|
+
bws.append(stats.bandwidth(fv, self.get("bandwidth"), w) * self.get("adjust"))
|
|
88
|
+
cut = self.get("cut")
|
|
89
|
+
lo, hi = math.inf, -math.inf
|
|
90
|
+
for fv, bw in zip(fsets, bws):
|
|
91
|
+
n, a, b = _backend.extent(fv, None)
|
|
92
|
+
if n:
|
|
93
|
+
lo, hi = min(lo, a - cut * bw), max(hi, b + cut * bw)
|
|
94
|
+
if not math.isfinite(lo):
|
|
95
|
+
lo, hi = 0.0, 1.0
|
|
96
|
+
clip = self.get("clip")
|
|
97
|
+
if clip is not None:
|
|
98
|
+
lo, hi = max(lo, scale.f(clip[0])), min(hi, scale.f(clip[1]))
|
|
99
|
+
n = self.get("grid")
|
|
100
|
+
dens = [stats.kde(fv, lo, hi, n, bw, w, self.get("cumulative"))
|
|
101
|
+
for fv, bw, w in zip(fsets, bws, ws)]
|
|
102
|
+
grid = array.array("d", (scale.inverse(lo + (hi - lo) * i / (n - 1)) for i in range(n)))
|
|
103
|
+
return {"grid": grid, "densities": dens, "bandwidths": bws}
|
|
104
|
+
|
|
105
|
+
def stack_layers(self) -> list[tuple[list[Any], Any]]:
|
|
106
|
+
st = self.stats
|
|
107
|
+
keys = [round(g, 12) for g in st["grid"]]
|
|
108
|
+
return [(keys, d) for d in st["densities"]]
|
|
109
|
+
|
|
110
|
+
def _ends(self) -> list[tuple[list[float], list[float]]]:
|
|
111
|
+
if self.get("stack"):
|
|
112
|
+
return stacked(self)
|
|
113
|
+
return [([0.0] * len(d), list(d)) for d in self.stats["densities"]]
|
|
114
|
+
|
|
115
|
+
def values(self, role: str) -> Any:
|
|
116
|
+
if role == self._val_role():
|
|
117
|
+
return self.stats["grid"]
|
|
118
|
+
out = array.array("d", [0.0])
|
|
119
|
+
for _, t in self._ends():
|
|
120
|
+
out.extend(t)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
def zero_default(self, role: str) -> bool:
|
|
124
|
+
return role != self._val_role()
|
|
125
|
+
|
|
126
|
+
def zero_padding(self, role: str) -> bool:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
def palette_count(self) -> int:
|
|
130
|
+
return len(self.sets)
|
|
131
|
+
|
|
132
|
+
def has_own_color(self) -> bool:
|
|
133
|
+
return self.is_set("color")
|
|
134
|
+
|
|
135
|
+
def legend_labels(self) -> list[Any]:
|
|
136
|
+
if self.named:
|
|
137
|
+
return [str(k) for k, _ in self.sets]
|
|
138
|
+
name = self.get("name")
|
|
139
|
+
return [] if name is None else [name]
|
|
140
|
+
|
|
141
|
+
def occupy(self, ctx: Any, occ: Any) -> None:
|
|
142
|
+
t = self.stats["grid"]
|
|
143
|
+
filled = self.get("fill") or self.get("stack")
|
|
144
|
+
for bottom, top in self._ends():
|
|
145
|
+
if self._horizontal():
|
|
146
|
+
if filled:
|
|
147
|
+
occ.band(ctx.ymap.many(t), ctx.xmap.many(bottom), ctx.xmap.many(top), True)
|
|
148
|
+
else:
|
|
149
|
+
occ.lines(ctx.xmap.many(top), ctx.ymap.many(t))
|
|
150
|
+
elif filled:
|
|
151
|
+
occ.band(ctx.xmap.many(t), ctx.ymap.many(bottom), ctx.ymap.many(top))
|
|
152
|
+
else:
|
|
153
|
+
occ.lines(ctx.xmap.many(t), ctx.ymap.many(top))
|
|
154
|
+
|
|
155
|
+
def legend_points(self) -> tuple[Any, Any] | None:
|
|
156
|
+
g = self.stats["grid"]
|
|
157
|
+
xs, ys = array.array("d"), array.array("d")
|
|
158
|
+
for _, t in self._ends():
|
|
159
|
+
xs.extend(g)
|
|
160
|
+
ys.extend(t)
|
|
161
|
+
return (ys, xs) if self._horizontal() else (xs, ys)
|
|
162
|
+
|
|
163
|
+
def colors(self, ctx: Any) -> list[Any]:
|
|
164
|
+
c = self.get("color")
|
|
165
|
+
n = len(self.sets)
|
|
166
|
+
if isinstance(c, list):
|
|
167
|
+
if len(c) != n:
|
|
168
|
+
raise ValueError(f"{len(c)} colors for {n} sets")
|
|
169
|
+
return [ctx.color(x) for x in c]
|
|
170
|
+
if c is not None:
|
|
171
|
+
return [ctx.color(c)] * n
|
|
172
|
+
return self.auto_colors(ctx)
|
|
173
|
+
|
|
174
|
+
def _fill_alpha(self) -> float:
|
|
175
|
+
return self.get("alpha")
|
|
176
|
+
|
|
177
|
+
def draw(self, ctx: Any) -> None:
|
|
178
|
+
g = self.stats["grid"]
|
|
179
|
+
horiz = self._horizontal()
|
|
180
|
+
vmap, dmap = (ctx.ymap, ctx.xmap) if horiz else (ctx.xmap, ctx.ymap)
|
|
181
|
+
pg = vmap.many(g)
|
|
182
|
+
lw = self.get("line_width")
|
|
183
|
+
dash = dash_of(self.get("line_style"), lw)
|
|
184
|
+
fill = self.get("fill") or self.get("stack")
|
|
185
|
+
for (bot, top), col in zip(self._ends(), self.colors(ctx)):
|
|
186
|
+
if col is None:
|
|
187
|
+
continue
|
|
188
|
+
pt, pb = dmap.many(top), dmap.many(bot)
|
|
189
|
+
if fill:
|
|
190
|
+
rx = array.array("d", pg)
|
|
191
|
+
rx.extend(reversed(pg))
|
|
192
|
+
ry = array.array("d", pt)
|
|
193
|
+
ry.extend(reversed(pb))
|
|
194
|
+
ring = (ry, rx) if horiz else (rx, ry)
|
|
195
|
+
fill_polygons(ctx.canvas, [[ring]], fill=with_alpha(col, self._fill_alpha()))
|
|
196
|
+
xs, ys = (pt, pg) if horiz else (pg, pt)
|
|
197
|
+
if lw:
|
|
198
|
+
ctx.canvas.draw_lines(xs, ys, color=col, width=lw, dash=dash,
|
|
199
|
+
simplify=self.simplify_arg(lines=True))
|
|
200
|
+
|
|
201
|
+
def legend_colors_at(self, ctx: Any, i: int) -> tuple[Any, Any]:
|
|
202
|
+
cols = self.colors(ctx)
|
|
203
|
+
c = cols[i] if i < len(cols) else None
|
|
204
|
+
fill = with_alpha(c, self._fill_alpha()) if c is not None and (
|
|
205
|
+
self.get("fill") or self.get("stack")) else None
|
|
206
|
+
return fill, c
|
|
207
|
+
|
|
208
|
+
def legend_glyph_at(self, ctx: Any, i: int, x: float, y: float, w: float, h: float) -> None:
|
|
209
|
+
fill, c = self.legend_colors_at(ctx, i)
|
|
210
|
+
canvas = ctx.canvas
|
|
211
|
+
if fill is not None:
|
|
212
|
+
canvas.fill_style = fill
|
|
213
|
+
canvas.fill_rect(x, y, w, h)
|
|
214
|
+
if c is not None:
|
|
215
|
+
lw = self.get("line_width")
|
|
216
|
+
canvas.draw_lines([x, x + w], [y + h / 2, y + h / 2] if fill is None else [y, y],
|
|
217
|
+
color=c, width=lw, dash=dash_of(self.get("line_style"), lw))
|
|
218
|
+
|
|
219
|
+
def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
|
|
220
|
+
self.legend_glyph_at(ctx, 0, x, y, w, h)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _kde2d() -> type:
|
|
224
|
+
from .kde2d import Kde2d
|
|
225
|
+
return Kde2d
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
Kde.with_y = staticmethod(_kde2d) # type: ignore[attr-defined]
|
plot_io/series/kde2d.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""2-D kernel density contours (``kde(x, y)``): seaborn's ``kdeplot(x=, y=)``."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .. import _backend, stats
|
|
8
|
+
from ..density import Density, _bandwidth
|
|
9
|
+
from ..settings import Setting, color, number, table
|
|
10
|
+
from .contour import Contour
|
|
11
|
+
|
|
12
|
+
__all__ = ["Kde2d"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Kde2d(Contour):
|
|
16
|
+
"""Density contours of points ``(x, y)``: the 2-D Gaussian density on a grid over the
|
|
17
|
+
data plus ``cut`` bandwidths, drawn at levels enclosing equal shares of the mass."""
|
|
18
|
+
|
|
19
|
+
SETTINGS = table(
|
|
20
|
+
**{k: v for k, v in Contour.SETTINGS.items() if k not in ("levels", "colorbar")},
|
|
21
|
+
levels=Setting(lambda v: v if isinstance(v, list) else number(lo=1, integer=True)(v), doc="A number of iso-proportion levels (contour k of n encloses "
|
|
22
|
+
"k/n of the mass, above `thresh`), or density values."),
|
|
23
|
+
thresh=Setting(number(0, 1),
|
|
24
|
+
doc="Lowest iso-proportion: the outermost contour encloses 1 − "
|
|
25
|
+
"`thresh` of the mass."),
|
|
26
|
+
bandwidth=Setting(_bandwidth,
|
|
27
|
+
doc="`\"scott\"`, `\"silverman\"`, a number or a 2 × 2 matrix, as "
|
|
28
|
+
"`Density`."),
|
|
29
|
+
adjust=Setting(number(positive=True), doc="Factor of the bandwidth."),
|
|
30
|
+
cut=Setting(number(lo=0), doc="Grid extends this many bandwidths past the data."),
|
|
31
|
+
gridsize=Setting(number(lo=16, hi=1024, integer=True),
|
|
32
|
+
doc="Grid points per side."),
|
|
33
|
+
color=Setting(color, doc="Line color of unfilled contours without a `color_map`; "
|
|
34
|
+
"default the next palette color."),
|
|
35
|
+
colorbar=Setting(lambda v: bool(v), doc="Attach a colorbar to the plot."),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def __init__(self, x: Any, y: Any, plot: Any = None, **settings: Any) -> None:
|
|
39
|
+
self.px, self.py = _backend.doubles(x), _backend.doubles(y)
|
|
40
|
+
if len(self.px) != len(self.py):
|
|
41
|
+
raise ValueError(f"x has {len(self.px)} values and y {len(self.py)}")
|
|
42
|
+
self.h = self.w = 2
|
|
43
|
+
self.flat = array.array("d", [0.0] * 4)
|
|
44
|
+
self.xn = self.yn = array.array("d", [0.0, 1.0])
|
|
45
|
+
self._map = None
|
|
46
|
+
self._bar = None
|
|
47
|
+
from . import Series
|
|
48
|
+
Series.__init__(self, plot=plot, **settings)
|
|
49
|
+
if self.get("colorbar"):
|
|
50
|
+
self._bar = self.plot.colorbar(self)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def USES_PALETTE(self) -> bool: # type: ignore[override]
|
|
54
|
+
return not self.get("filled") and not self.is_set("color_map")
|
|
55
|
+
|
|
56
|
+
def has_own_color(self) -> bool:
|
|
57
|
+
return self.is_set("color")
|
|
58
|
+
|
|
59
|
+
def _density(self) -> Density:
|
|
60
|
+
return Density(self.get("bandwidth"), self.get("adjust"))
|
|
61
|
+
|
|
62
|
+
def _grid(self) -> dict[str, Any]:
|
|
63
|
+
sx, sy = self._scales()
|
|
64
|
+
return self.cached(("grid", sx.name, sy.name), lambda: self._compute_grid(sx, sy))
|
|
65
|
+
|
|
66
|
+
def _compute_grid(self, sx: Any, sy: Any) -> dict[str, Any]:
|
|
67
|
+
fx = _backend.scaled(self.px, 1.0, 0.0, sx.function, **sx.options)
|
|
68
|
+
fy = _backend.scaled(self.py, 1.0, 0.0, sy.function, **sy.options)
|
|
69
|
+
d = self._density()
|
|
70
|
+
h = d.covariance(fx, fy)
|
|
71
|
+
n = int(self.get("gridsize"))
|
|
72
|
+
_, x0, x1 = _backend.extent(fx, None)
|
|
73
|
+
_, y0, y1 = _backend.extent(fy, None)
|
|
74
|
+
if not all(math.isfinite(v) for v in (x0, x1, y0, y1)) or \
|
|
75
|
+
not all(math.isfinite(v) for v in h):
|
|
76
|
+
return {"fx": array.array("d", [0.0, 1.0]), "fy": array.array("d", [0.0, 1.0]),
|
|
77
|
+
"flat": array.array("d", [0.0] * 4), "w": 2, "h": 2}
|
|
78
|
+
cut = self.get("cut")
|
|
79
|
+
x0, x1 = x0 - cut * math.sqrt(h[0]), x1 + cut * math.sqrt(h[0])
|
|
80
|
+
y0, y1 = y0 - cut * math.sqrt(h[2]), y1 + cut * math.sqrt(h[2])
|
|
81
|
+
flat = _backend.kde2d_grid(fx, fy, h, x0, x1, n, y0, y1, n)
|
|
82
|
+
gx = array.array("d", (x0 + (x1 - x0) * i / (n - 1) for i in range(n)))
|
|
83
|
+
gy = array.array("d", (y0 + (y1 - y0) * j / (n - 1) for j in range(n)))
|
|
84
|
+
return {"fx": gx, "fy": gy, "flat": flat, "w": n, "h": n}
|
|
85
|
+
|
|
86
|
+
def level_values(self) -> list[float]:
|
|
87
|
+
lv = self.get("levels")
|
|
88
|
+
if isinstance(lv, list):
|
|
89
|
+
return sorted(float(v) for v in lv)
|
|
90
|
+
g = self._grid()
|
|
91
|
+
values = sorted((v for v in g["flat"] if v == v), reverse=True)
|
|
92
|
+
total = sum(values)
|
|
93
|
+
if not total:
|
|
94
|
+
return [0.0]
|
|
95
|
+
thresh = self.get("thresh")
|
|
96
|
+
props = [thresh + (1.0 - thresh) * i / (lv - 1) for i in range(lv)] if lv > 1 \
|
|
97
|
+
else [thresh]
|
|
98
|
+
cum, out, acc, j = [], [], 0.0, 0
|
|
99
|
+
for v in values:
|
|
100
|
+
acc += v
|
|
101
|
+
cum.append(acc / total)
|
|
102
|
+
for p in props: # density whose upper set holds 1 − p
|
|
103
|
+
target = 1.0 - p
|
|
104
|
+
while j < len(cum) - 1 and cum[j] < target:
|
|
105
|
+
j += 1
|
|
106
|
+
out.append(values[min(j, len(values) - 1)])
|
|
107
|
+
j = 0
|
|
108
|
+
return sorted(set(out))
|
|
109
|
+
|
|
110
|
+
def _compute(self, sx: Any, sy: Any) -> dict[str, Any]:
|
|
111
|
+
g = self._grid()
|
|
112
|
+
lv = self.level_values()
|
|
113
|
+
lines: dict[float, Any] = {}
|
|
114
|
+
if self.get("lines"):
|
|
115
|
+
for level in lv:
|
|
116
|
+
lines[level] = stats.contour_lines(g["flat"], g["w"], g["h"], g["fx"], g["fy"],
|
|
117
|
+
level)
|
|
118
|
+
bands = []
|
|
119
|
+
if self.get("filled"):
|
|
120
|
+
tops = lv[1:] + [math.inf]
|
|
121
|
+
for lo, hi in zip(lv, tops):
|
|
122
|
+
top = hi if math.isfinite(hi) else max(g["flat"]) * 1.0001 + 1e-300
|
|
123
|
+
rings = stats.contour_bands(g["flat"], g["w"], g["h"], g["fx"], g["fy"], lo, top)
|
|
124
|
+
if len(rings[0]):
|
|
125
|
+
bands.append((lo, hi if math.isfinite(hi) else top, rings))
|
|
126
|
+
return {"levels": lv, "lines": lines, "bands": bands, "f_space": True}
|
|
127
|
+
|
|
128
|
+
def values(self, role: str) -> Any:
|
|
129
|
+
sx, sy = self._scales()
|
|
130
|
+
g = self._grid()
|
|
131
|
+
f, s = (g["fx"], sx) if role == "x" else (g["fy"], sy)
|
|
132
|
+
return array.array("d", [s.inverse(f[0]), s.inverse(f[-1])])
|
|
133
|
+
|
|
134
|
+
def _level_color(self, ctx: Any, i: int, level: float) -> Any:
|
|
135
|
+
if not self.get("filled") and not self.is_set("color_map") \
|
|
136
|
+
and self.get("colors") is None:
|
|
137
|
+
return self.base_color(ctx)
|
|
138
|
+
return super()._level_color(ctx, i, level)
|
|
139
|
+
|
|
140
|
+
def color_sources(self) -> list[tuple[Any, Any]]:
|
|
141
|
+
if not self.get("filled") and not self.is_set("color_map"):
|
|
142
|
+
return []
|
|
143
|
+
return super().color_sources()
|
|
144
|
+
|
|
145
|
+
def column_role(self, param: str) -> str | None:
|
|
146
|
+
return param if param in ("x", "y") else None
|
|
147
|
+
|
|
148
|
+
def legend_points(self) -> tuple[Any, Any] | None:
|
|
149
|
+
return self.px, self.py
|
|
150
|
+
|
|
151
|
+
def occupy(self, ctx: Any, occ: Any) -> None:
|
|
152
|
+
from . import Series
|
|
153
|
+
Series.occupy(self, ctx, occ)
|