plot-io 0.12.0__py3-none-win_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-arm64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,202 @@
1
+ """Clustered heatmaps: a matrix with its rows and columns in the leaf order of their
2
+ clustering trees, the dendrograms beside it, and annotation strips between them."""
3
+
4
+ import array
5
+ import math
6
+ from typing import Any
7
+
8
+ from .. import Figure, kernels
9
+ from .grids import _split
10
+ from .tree import Tree, _frame, linkage
11
+
12
+ __all__ = ["clustermap", "ClusterMap"]
13
+
14
+ _SIDES = ("right", "left", "top", "bottom")
15
+
16
+
17
+ class ClusterMap:
18
+ """A clustered heatmap (``plt.clustermap``): ``heatmap`` (the series) on ``plot``,
19
+ ``row_tree`` and ``col_tree`` (``None`` when not clustered), ``row_order`` and
20
+ ``col_order`` (the matrix's row and column indices, top to bottom and left to right),
21
+ ``row_dendrogram`` and ``col_dendrogram`` (series or ``None``), ``row_colors`` and
22
+ ``col_colors`` (the strips' series or ``None``), ``colorbar`` (the heatmap's colorbar,
23
+ a :class:`~plot_io.colorbar.FigureColorbar` whose ``.set`` changes its label, ticks or
24
+ side; ``None`` with ``colorbar=None``)."""
25
+
26
+ def __init__(self, figure: Figure) -> None:
27
+ self.figure = figure
28
+ self.heatmap: Any = None
29
+ self.plot: Any = None
30
+ self.row_tree: Tree | None = None
31
+ self.col_tree: Tree | None = None
32
+ self.row_order: list[int] = []
33
+ self.col_order: list[int] = []
34
+ self.row_dendrogram: Any = None
35
+ self.col_dendrogram: Any = None
36
+ self.row_colors: Any = None
37
+ self.col_colors: Any = None
38
+ self.colorbar: Any = None
39
+
40
+ def __repr__(self) -> str:
41
+ return f"ClusterMap({len(self.row_order)} × {len(self.col_order)})"
42
+
43
+
44
+ def _matrix(values: Any) -> list[array.array]:
45
+ g = kernels.grid(values)
46
+ if g is not None:
47
+ h, w, flat = g[0], g[1], g[3]
48
+ return [array.array("d", flat[i * w:(i + 1) * w]) for i in range(h)]
49
+ rows = [kernels.doubles(r) for r in values]
50
+ if rows and any(len(r) != len(rows[0]) for r in rows):
51
+ raise ValueError("clustermap: rows of different lengths")
52
+ return rows
53
+
54
+
55
+ def _scaled(m: list[array.array], axis: str, how: str) -> list[array.array]:
56
+ """``how="z"``: z-scores (sample standard deviation); ``"01"``: (v − min) / (max −
57
+ min); of each row (``axis="rows"``) or column."""
58
+ if axis == "cols":
59
+ w = len(m[0]) if m else 0
60
+ cols = _scaled([array.array("d", (r[j] for r in m)) for j in range(w)], "rows", how)
61
+ return [array.array("d", (c[i] for c in cols)) for i in range(len(m))]
62
+ out = []
63
+ for r in m:
64
+ v = [x for x in r if not math.isnan(x)]
65
+ if how == "z":
66
+ mean = sum(v) / len(v) if v else 0.0
67
+ sd = math.sqrt(sum((x - mean) ** 2 for x in v) / (len(v) - 1)) if len(v) > 1 else 0.0
68
+ a, b = mean, sd
69
+ else:
70
+ a = min(v) if v else 0.0
71
+ b = (max(v) - a) if v else 0.0
72
+ out.append(array.array("d", (((x - a) / b if b else math.nan) for x in r)))
73
+ return out
74
+
75
+
76
+ def _axis_value(name: str, v: Any) -> Any:
77
+ if v not in (None, "rows", "cols"):
78
+ raise ValueError(f"clustermap: {name} is 'rows', 'cols' or None, got {v!r}")
79
+ return v
80
+
81
+
82
+ def _tree(given: Any, m: list[array.array], axis: str, labels: list[str], method: str,
83
+ metric: str) -> Tree | None:
84
+ if given is False or given is None:
85
+ return None
86
+ if given is True:
87
+ return linkage(m, method=method, metric=metric, axis=axis, labels=labels)
88
+ t = given if isinstance(given, Tree) else Tree(given)
89
+ if t.n != len(labels):
90
+ raise ValueError(f"clustermap: a tree of {t.n} leaves for {len(labels)} {axis}")
91
+ return t
92
+
93
+
94
+ def _hidden(plot: Any) -> Any:
95
+ plot.x_axis(visible=False)
96
+ plot.y_axis(visible=False)
97
+ return plot
98
+
99
+
100
+ def clustermap(matrix: Any, x: Any = None, y: Any = None, method: str = "average",
101
+ metric: str = "euclidean", rows: Any = True, cols: Any = True,
102
+ scale: Any = None, standard_scale: Any = None, row_colors: Any = None,
103
+ col_colors: Any = None, tree_size: Any = "15%", colorbar: Any = "right",
104
+ **heatmap_settings: Any) -> tuple[Figure, ClusterMap]:
105
+ """A heatmap of ``matrix`` (rows first: a 2-D buffer, a sequence of rows or a data
106
+ frame, whose index and columns name them) with its rows and columns in the leaf order
107
+ of their trees (``rows``, ``cols``: ``True`` clusters them with ``method`` and
108
+ ``metric``, as ``plt.linkage``; a :class:`Tree` or a scipy ``Z``; ``False`` keeps the
109
+ order), the dendrograms left and top, ``tree_size`` wide. ``x``, ``y``: the column and
110
+ row labels (default: the frame's, else the indices). ``scale``: z-scores of each row
111
+ (``"rows"``) or column (``"cols"``); ``standard_scale``: each scaled to 0…1; both before
112
+ clustering and drawing. ``row_colors``, ``col_colors``: an annotation strip between
113
+ the dendrogram and the heatmap, one value per row (column): colours drawn as given, or
114
+ categories as levels (one colour each, in the legend). ``colorbar``: its side, or
115
+ ``None`` (it is ``ClusterMap.colorbar``). ``heatmap_settings`` go to the heatmap (the
116
+ figure's to the figure; default 480 × 480 px). Returns the figure and its :class:`ClusterMap`.
117
+
118
+ Example::
119
+
120
+ rng = random.Random(1)
121
+ matrix = [[rng.gauss(r % 3, 1) + c % 2 for c in range(8)] for r in range(12)]
122
+ fig, cm = plt.clustermap(matrix, width=260, height=240)
123
+ """
124
+ if scale is not None and standard_scale is not None:
125
+ raise ValueError("clustermap: give scale or standard_scale, not both")
126
+ _axis_value("scale", scale)
127
+ _axis_value("standard_scale", standard_scale)
128
+ if colorbar not in (None, False) and colorbar not in _SIDES:
129
+ raise ValueError(f"colorbar is None or one of {', '.join(map(repr, _SIDES))}")
130
+ values, row_names, col_names = _frame(matrix)
131
+ m = _matrix(values)
132
+ if not m or not m[0]:
133
+ raise ValueError("clustermap: an empty matrix")
134
+ nr, nc = len(m), len(m[0])
135
+ ylab = [str(v) for v in (y if y is not None else row_names or range(nr))]
136
+ xlab = [str(v) for v in (x if x is not None else col_names or range(nc))]
137
+ if len(ylab) != nr or len(xlab) != nc:
138
+ raise ValueError(f"clustermap: {len(ylab)} row and {len(xlab)} column labels for a "
139
+ f"{nr} × {nc} matrix")
140
+ if scale is not None:
141
+ m = _scaled(m, scale, "z")
142
+ elif standard_scale is not None:
143
+ m = _scaled(m, standard_scale, "01")
144
+ fig_kw = _split("heatmap", heatmap_settings)
145
+ fig_kw.setdefault("reference", "figure")
146
+ sized = [k for k in ("width", "height") if k in fig_kw]
147
+ if not sized:
148
+ fig_kw["width"] = fig_kw["height"] = 480
149
+ elif len(sized) == 1:
150
+ fig_kw.setdefault("aspect_ratio", 1)
151
+ for k, v in (("share_legend", "all"), ("spacing", 2)):
152
+ fig_kw.setdefault(k, v)
153
+ rt = _tree(rows, m, "rows", ylab, method, metric)
154
+ ct = _tree(cols, m, "cols", xlab, method, metric)
155
+ left = (["rt"] if rt else []) + (["rc"] if row_colors is not None else [])
156
+ above = (["ct"] if ct else []) + (["cc"] if col_colors is not None else [])
157
+ strip = "1em"
158
+ widths = [tree_size if k == "rt" else strip for k in left] + [1]
159
+ heights = [tree_size if k == "ct" else strip for k in above] + [1]
160
+ grid = [[0] * len(left) + [k] for k in above] + [left + ["hm"]]
161
+ fig = Figure(layout=grid, widths=widths, heights=heights, **fig_kw)
162
+ cm = ClusterMap(fig)
163
+ cm.row_tree, cm.col_tree = rt, ct
164
+ cm.row_order = list(rt.leaves) if rt else list(range(nr))
165
+ cm.col_order = list(ct.leaves) if ct else list(range(nc))
166
+ ro, co = cm.row_order, cm.col_order
167
+ hm = cm.plot = fig.plot("hm")
168
+ hm.y_axis(side="right")
169
+ heatmap_settings.setdefault("origin", "top")
170
+ cm.heatmap = hm.heatmap([[r[j] for j in co] for r in (m[i] for i in ro)],
171
+ x=[xlab[j] for j in co], y=[ylab[i] for i in ro],
172
+ **heatmap_settings)
173
+ if rt:
174
+ p = _hidden(fig.plot("rt", share_y=hm, spines="none"))
175
+ cm.row_dendrogram = p.dendrogram(rt, labels=ylab, orientation="horizontal")
176
+ if ct:
177
+ p = _hidden(fig.plot("ct", share_x=hm, spines="none"))
178
+ cm.col_dendrogram = p.dendrogram(ct, labels=xlab)
179
+ bar = {"width": "100%", "fill_opacity": 1, "edge_width": 0}
180
+ if row_colors is not None:
181
+ p = _hidden(fig.plot("rc", share_y=hm, spines="none"))
182
+ p.x_axis(limits="0/1", padding=0)
183
+ cm.row_colors = p.bar([ylab[i] for i in ro], [1.0] * nr,
184
+ orientation="horizontal",
185
+ **_strip(row_colors, ro, "row_colors"), **bar)
186
+ if col_colors is not None:
187
+ p = _hidden(fig.plot("cc", share_x=hm, spines="none"))
188
+ p.y_axis(limits="0/1", padding=0)
189
+ cm.col_colors = p.bar([xlab[j] for j in co], [1.0] * nc,
190
+ **_strip(col_colors, co, "col_colors"), **bar)
191
+ if colorbar:
192
+ cm.colorbar = fig.add_colorbar(cm.heatmap, plots=[hm], side=colorbar)
193
+ return fig, cm
194
+
195
+
196
+ def _strip(values: Any, order: list[int], name: str) -> dict[str, Any]:
197
+ """A strip's ``color`` in the drawn order; categories keep their order of appearance
198
+ in the matrix (``level_order``)."""
199
+ v = values.tolist() if hasattr(values, "tolist") else list(values)
200
+ if len(v) != len(order):
201
+ raise ValueError(f"clustermap: {len(v)} {name} for {len(order)}")
202
+ return {"color": [v[i] for i in order], "level_order": list(dict.fromkeys(v))}
@@ -0,0 +1,172 @@
1
+ """Dendrograms: the merges of a :class:`~plot_io.helpers.tree.Tree` as U-shaped links."""
2
+
3
+ import array
4
+ from typing import Any
5
+
6
+ from .. import ext
7
+ from .._methods import DendrogramMixin
8
+ from .tree import Tree
9
+
10
+ __all__ = ["Dendrogram"]
11
+
12
+
13
+ class Dendrogram(DendrogramMixin, ext.Series):
14
+ """The merges of ``tree`` (a :class:`Tree`, or a scipy ``Z``) as U-shaped links: the
15
+ leaves at slots 0, 1, … in ``tree.leaves`` order on the leaf axis, the heights on the
16
+ value axis, the root at the top (``orientation="horizontal"``: at the left, heights
17
+ along x, the x axis inverted unless set). ``labels`` (default: the tree's) make the
18
+ leaf axis categorical, in leaf order, so a heatmap with the same categories shares it;
19
+ without labels each leaf is ticked with its observation's index. Every leaf is
20
+ labelled: turned 90° when the labels do not fit flat, then smaller, down to the axis'
21
+ ``label_min_size`` (6 pt), and every k-th only below it.
22
+
23
+ ``clusters=k`` draws the links inside each of the ``k`` flat clusters in the next
24
+ plot's colors, the others in ``color`` (default: the text color). Trees come from
25
+ :func:`linkage`, or ``plt.Tree(Z, labels=None)`` of a scipy linkage matrix.
26
+
27
+ Example::
28
+
29
+ rng = random.Random(1)
30
+ rows = [[rng.gauss(c, 1) for _ in range(4)] for c in (0, 0, 0, 4, 4, 8, 8, 8)]
31
+ p = plt.Plot(width=240, height=160)
32
+ p.dendrogram(plt.linkage(rows), labels=list("abcdefgh"), clusters=3)
33
+ """
34
+
35
+ SETTINGS = ext.table(
36
+ color=ext.Setting(ext.color, doc="Line color; default: the text color."),
37
+ clusters=ext.Setting(ext.number(lo=1, integer=True),
38
+ doc="Cut into this many flat clusters (`Tree.cut`): the links "
39
+ "within each drawn in the plot's next color."),
40
+ orientation=ext.Setting(ext.one_of("vertical", "horizontal"),
41
+ doc="`vertical`: leaves along x, heights on y; `horizontal`: "
42
+ "leaves along y, heights on x."),
43
+ **ext.common("line_width", "name", "opacity", "z", "text_color"),
44
+ )
45
+
46
+ def __init__(self, tree: Any, labels: Any = None, plot: Any = None,
47
+ **settings: Any) -> None:
48
+ t = tree if isinstance(tree, Tree) else Tree(tree)
49
+ labels = t.labels if labels is None else list(labels)
50
+ if labels is not None:
51
+ if len(labels) != t.n:
52
+ raise ValueError(f"Dendrogram: {len(labels)} labels for {t.n} leaves")
53
+ if len(set(labels)) != len(labels):
54
+ raise ValueError("Dendrogram: labels must differ (they are categories)")
55
+ #: the tree drawn
56
+ self.tree = t
57
+ #: the names of the leaves (``None``: their indices)
58
+ self.labels = labels
59
+ super().__init__(plot=plot, **settings)
60
+
61
+ def line_color(self, ctx: Any) -> Any:
62
+ """The color of the links outside the clusters."""
63
+ c = self.resolve("color")
64
+ return ctx.color(self.resolve("text_color") if c is None else c)
65
+
66
+ def cluster_colors(self, ctx: Any) -> list[Any]:
67
+ """The links' colors: outside the clusters, then each cluster's."""
68
+ k = self._clusters()
69
+ return [self.line_color(ctx)] + (ext.auto_colors(self, ctx)[:k] if k else [])
70
+
71
+ def _clusters(self) -> int:
72
+ k = self.resolve("clusters")
73
+ return min(int(k), self.tree.n) if k is not None and self.tree.n > 1 else 0
74
+
75
+ def _flip(self) -> bool:
76
+ return self.resolve("orientation") == "horizontal"
77
+
78
+ def _groups(self) -> list[list[int]]:
79
+ """The merges drawn in each color: outside the clusters, then per cluster."""
80
+ t, k = self.tree, self._clusters()
81
+ n = t.n
82
+ if not k:
83
+ return [list(range(n - 1))]
84
+ label = t.cut(k=k) + [0] * (n - 1)
85
+ out: list[list[int]] = [[] for _ in range(k + 1)]
86
+ for i, r in enumerate(t.merges):
87
+ a, b = label[int(r[0])], label[int(r[1])]
88
+ label[n + i] = a if a == b else 0
89
+ out[label[n + i]].append(i)
90
+ return out
91
+
92
+ def _positions(self, categories: Any) -> list[float]:
93
+ """Each observation's slot: its leaf's index, or its label's on the axis."""
94
+ t = self.tree
95
+ pos = [0.0] * t.n
96
+ if categories is None or self.labels is None:
97
+ for i, leaf in enumerate(t.leaves):
98
+ pos[leaf] = float(i)
99
+ else:
100
+ index: dict[Any, float] = {}
101
+ for i, c in enumerate(categories):
102
+ index.setdefault(c, float(i))
103
+ pos = [index.get(c, float("nan")) for c in self.labels]
104
+ return pos
105
+
106
+ def _links(self, pos: list[float], merges: list[int]) -> dict[str, array.array]:
107
+ """The three segments of each merge in ``merges``: ``x0, x1, y0, y1``."""
108
+ t = self.tree
109
+ n = t.n
110
+ x = pos + [0.0] * (n - 1)
111
+ h = [0.0] * n + [r[2] for r in t.merges]
112
+ for i, r in enumerate(t.merges):
113
+ x[n + i] = (x[int(r[0])] + x[int(r[1])]) / 2
114
+ out = {k: array.array("d") for k in ("x0", "x1", "y0", "y1")}
115
+ for i in merges:
116
+ a, b, top = int(t.merges[i][0]), int(t.merges[i][1]), t.merges[i][2]
117
+ out["x0"].extend((x[a], x[b], x[a]))
118
+ out["x1"].extend((x[a], x[b], x[b]))
119
+ out["y0"].extend((h[a], h[b], top))
120
+ out["y1"].extend((top, top, top))
121
+ return out
122
+
123
+
124
+ def _tables(s: Dendrogram, b: Any) -> list[Any] | None:
125
+ if s.tree.n < 2:
126
+ return None
127
+ groups = s._groups()
128
+ role = "y" if s._flip() else "x"
129
+ seen: dict[str, Any] = {}
130
+
131
+ def links(ctx: Any) -> list[dict[str, array.array]]:
132
+ cats = (ctx.y if role == "y" else ctx.x).categories
133
+ if seen.get("cats") is not cats:
134
+ pos = s._positions(cats)
135
+ seen["cats"], seen["links"] = cats, [s._links(pos, g) for g in groups]
136
+ return seen["links"]
137
+
138
+ def column(k: int, c: str) -> Any:
139
+ return lambda ctx: links(ctx)[k][c]
140
+
141
+ pos = s._positions(None)
142
+ out = []
143
+ for k, g in enumerate(groups):
144
+ t = ext.Table(**s._links(pos, g))
145
+ if s.labels is not None:
146
+ for c in ("x0", "x1"):
147
+ t.lazy[c] = column(k, c)
148
+ out.append(t)
149
+ return out
150
+
151
+
152
+ def _finish(s: Dendrogram, b: Any) -> None:
153
+ call, n = b.call, s.tree.n
154
+ leaf = "y" if s._flip() else "x"
155
+ call.color_count = s._clusters()
156
+ call.leaves.add(leaf)
157
+ if s._flip():
158
+ call.invert = {"x"}
159
+ if s.labels is not None:
160
+ call.cats[leaf] = [s.labels[i] for i in s.tree.leaves]
161
+ return
162
+ call.extra[leaf] = array.array("d", [-0.5, n - 0.5])
163
+ call.exact.add(leaf)
164
+ call.ticks[leaf] = [(float(i), str(o)) for i, o in enumerate(s.tree.leaves)]
165
+
166
+
167
+ Dendrogram.SPEC = ext.Spec(
168
+ [ext.Lay("segments", ("stroke", {"color": "cluster_colors()", "opacity": True}),
169
+ table="links", per_set=True, key="first")],
170
+ tables={"links": _tables}, key={"stroke": "line_color()"}, zero=("y",), finish=_finish)
171
+
172
+ ext.register("dendrogram", Dendrogram)
@@ -0,0 +1,304 @@
1
+ """Joint plots and pair grids: figures of plots sharing axes, made by one call.
2
+
3
+ ``fig, joint = plt.joint("a", "b", data=df)``: a main plot with marginal distributions on
4
+ top and on the right. ``fig, pairs = plt.pairs(df)``: every pair of variables, with each
5
+ variable's distribution on the diagonal. Both own their figure (as ``plt.facet``); level
6
+ aesthetics (``color="group"``) take one colour per level in every panel and one legend.
7
+ """
8
+
9
+ import numbers
10
+ from typing import Any
11
+
12
+ from .. import Figure, ext
13
+
14
+ __all__ = ["joint", "pairs", "Joint", "Pairs"]
15
+
16
+ _MAIN = ("scatter", "hexbin", "hist2d", "kde2d", "regression")
17
+ #: kinds drawn by another type's method (``kde`` of x and y is the 2-D density)
18
+ _METHOD = {"kde2d": "kde"}
19
+ _MARGINAL = ("hist", "kde", "box", "violin", "strip")
20
+ _DIAGONAL = ("hist", "kde")
21
+ #: the call keywords marginal and diagonal calls take from the main call
22
+ _SHARED = ("color", "color_map", "level_order", "level_missing")
23
+
24
+
25
+ def _is_number(v: Any) -> bool:
26
+ return isinstance(v, numbers.Real) and not isinstance(v, bool)
27
+
28
+
29
+ def _column(data: Any, name: str) -> Any:
30
+ try:
31
+ return data[name]
32
+ except (KeyError, IndexError, TypeError, ValueError):
33
+ return None
34
+
35
+
36
+ def _numeric(values: Any) -> bool:
37
+ """Whether a column holds numbers (not text, booleans or categories)."""
38
+ dtype = getattr(values, "dtype", None)
39
+ if dtype is not None:
40
+ if callable(getattr(dtype, "is_numeric", None)): # polars
41
+ return bool(dtype.is_numeric())
42
+ kind = getattr(dtype, "kind", None)
43
+ if kind is not None:
44
+ return kind in "iuf"
45
+ first = next((v for v in values if v is not None), None)
46
+ return _is_number(first)
47
+
48
+
49
+ def _color_kind(color: Any, data: Any) -> str | None:
50
+ """``"levels"`` (a column of categories), ``"plain"`` (one colour), ``"numbers"`` (a
51
+ column of numbers through a color map) or ``None``."""
52
+ if color is None:
53
+ return None
54
+ if isinstance(color, str):
55
+ values = _column(data, color) if data is not None else None
56
+ if values is None:
57
+ return "plain"
58
+ return "numbers" if _numeric(values) else "levels"
59
+ if isinstance(color, tuple) and len(color) in (3, 4) and all(map(_is_number, color)):
60
+ return "plain"
61
+ return "numbers" if _numeric(color) else "levels"
62
+
63
+
64
+ def _split(kind: str, settings: dict[str, Any]) -> dict[str, Any]:
65
+ """Take the figure's settings out of ``settings`` (those the plot type lacks)."""
66
+ from .. import registry
67
+ own = registry.layers.get(_METHOD.get(kind, kind)).SETTINGS
68
+ return {k: settings.pop(k) for k in list(settings)
69
+ if k not in own and k in Figure.SETTINGS}
70
+
71
+
72
+ def _shared(settings: dict[str, Any], data: Any) -> tuple[dict[str, Any], bool]:
73
+ """The keywords the distributions take from the main call, and whether ``color`` maps
74
+ levels."""
75
+ kind = _color_kind(settings.get("color"), data)
76
+ out = {k: settings[k] for k in _SHARED if settings.get(k) is not None}
77
+ if kind == "numbers":
78
+ out.pop("color", None)
79
+ if kind != "levels":
80
+ out.pop("color_map", None)
81
+ return out, kind == "levels"
82
+
83
+
84
+ def _check(name: str, value: Any, allowed: tuple[str, ...]) -> Any:
85
+ if value is not None and value != "auto" and value not in allowed:
86
+ raise ValueError(f"{name} is one of {', '.join(map(repr, allowed))}, 'auto' or None, "
87
+ f"got {value!r}")
88
+ return value
89
+
90
+
91
+ def _figure(grid: list[list[Any]], fig_kw: dict[str, Any]) -> Figure:
92
+ for k, v in (("share_color", "all"), ("share_size", "all"), ("share_legend", "all"),
93
+ ("label_outer", True)):
94
+ fig_kw.setdefault(k, v)
95
+ return Figure(layout=grid, **fig_kw)
96
+
97
+
98
+ class Joint:
99
+ """A joint plot (``plt.joint``): ``main`` and the marginal plots ``top`` (sharing x)
100
+ and ``right`` (sharing y); ``series`` (the main call's), ``marginals`` (the marginal
101
+ calls')."""
102
+
103
+ def __init__(self, figure: Figure, x: Any, y: Any, data: Any) -> None:
104
+ self.figure = figure
105
+ self.x, self.y, self.data = x, y, data
106
+ self.main = figure.plot("main")
107
+ self.top = figure.plot("top", share_x=self.main, legend=False, spines="bottom")
108
+ self.right = figure.plot("right", share_y=self.main, legend=False, spines="left")
109
+ self.top.y_axis(visible=False)
110
+ self.right.x_axis(visible=False)
111
+ self.series: Any = None
112
+ self.marginals: list[Any] = []
113
+
114
+ def __repr__(self) -> str:
115
+ return f"Joint({len(self.marginals)} marginal calls)"
116
+
117
+ def marginals_call(self, method: str, **settings: Any) -> list[Any]:
118
+ """``top.<method>(x, …)`` and ``right.<method>(y, …)`` (along y: box, violin and
119
+ strip vertical, the others ``orientation="horizontal"``); returns both series."""
120
+ if self.data is not None:
121
+ settings.setdefault("data", self.data)
122
+ across = method in ("box", "violin", "strip")
123
+ out = []
124
+ for plot, values, flip in ((self.top, self.x, across), (self.right, self.y, not across)):
125
+ kw = dict(settings)
126
+ if flip:
127
+ kw.setdefault("orientation", "horizontal")
128
+ out.append(getattr(plot, method)(values, **kw))
129
+ self.marginals += out
130
+ return out
131
+
132
+
133
+ def joint(x: Any, y: Any, data: Any = None, kind: str = "scatter", marginal: Any = "auto",
134
+ ratio: float = 5, gap: Any = "0.5em", **settings: Any) -> tuple[Figure, Joint]:
135
+ """A joint plot: ``kind`` (``scatter``, ``hexbin``, ``hist2d``, ``kde2d``,
136
+ ``regression``) of ``x`` and ``y`` in the main plot, ``marginal`` distributions
137
+ (``hist``, ``kde``, ``box``, ``violin``, ``strip``, ``None``; ``"auto"``: ``kde`` when
138
+ ``color`` maps levels, else ``hist``) above it and on its right, ``ratio`` times
139
+ narrower, ``gap`` apart. ``settings`` go to the main call (the figure's to the
140
+ figure); ``color``, ``level_order``, ``level_missing`` and (with levels) ``color_map`` to the
141
+ marginals too. ``width`` and ``height`` measure the whole figure (``reference="figure"``).
142
+ Returns the figure and its :class:`Joint`.
143
+
144
+ Example::
145
+
146
+ rng = random.Random(1)
147
+ x = [rng.gauss(0, 1) for _ in range(200)]
148
+ fig, joint = plt.joint(x, [v + rng.gauss(0, 0.6) for v in x], width=240, height=240)
149
+ """
150
+ if kind not in _MAIN:
151
+ raise ValueError(f"kind is one of {', '.join(map(repr, _MAIN))}, got {kind!r}")
152
+ _check("marginal", marginal, _MARGINAL)
153
+ if not _is_number(ratio) or ratio <= 0:
154
+ raise ValueError(f"ratio is a positive number, got {ratio!r}")
155
+ fig_kw = _split(kind, settings)
156
+ fig_kw.setdefault("reference", "figure")
157
+ fig_kw.setdefault("spacing", gap)
158
+ fig_kw.setdefault("widths", [ratio, 1])
159
+ fig_kw.setdefault("heights", [1, ratio])
160
+ fig = _figure([["top", 0], ["main", "right"]], fig_kw)
161
+ j = Joint(fig, x, y, data)
162
+ shared, levels = _shared(settings, data)
163
+ if data is not None:
164
+ settings["data"] = data
165
+ j.series = getattr(j.main, _METHOD.get(kind, kind))(x, y, **settings)
166
+ if marginal == "auto":
167
+ marginal = "kde" if levels else "hist"
168
+ if marginal is not None:
169
+ j.marginals_call(marginal, **shared)
170
+ return fig, j
171
+
172
+
173
+ class _Cells(ext.PanelSet):
174
+ """Cells of a pair grid: a call draws each cell's variables (``(x,)`` on the diagonal,
175
+ ``(x, y)`` elsewhere) before its own arguments."""
176
+
177
+ def __init__(self, figure: Figure, data: Any, panels: dict[Any, Any],
178
+ names: list[str]) -> None:
179
+ super().__init__(figure, data, panels)
180
+ self._names = names
181
+
182
+ def part(self, key: Any) -> tuple[str, ...]:
183
+ """The variables of cell ``key`` (row, column): ``(x,)`` or ``(x, y)``."""
184
+ i, j = key
185
+ return (self._names[j],) if i == j else (self._names[j], self._names[i])
186
+
187
+ def arguments(self, key: Any, args: tuple, kw: dict[str, Any]) -> tuple[tuple, dict]:
188
+ return self.part(key) + tuple(args), {**kw, "data": self.data}
189
+
190
+
191
+ def _cell_method(name: str) -> Any:
192
+ def method(self: _Cells, *args: Any, **settings: Any) -> Any:
193
+ return self.call(name, args, settings)
194
+ method.__name__ = name
195
+ method.__qualname__ = f"_Cells.{name}"
196
+ method.__doc__ = f"``plot.{name}(<the cell's variables>, …)`` on every cell."
197
+ return method
198
+
199
+
200
+ def _cell_methods() -> None:
201
+ """The plot types on cells take the data arguments from the cell (the typed methods
202
+ of a panel set require them)."""
203
+ from .. import registry
204
+ for name in registry.layers.names():
205
+ setattr(_Cells, name, _cell_method(name))
206
+
207
+
208
+ class Pairs:
209
+ """A pair grid (``plt.pairs``): ``vars``, and the :class:`~plot_io.facets.PanelSet`\\ s
210
+ ``lower``, ``upper`` (empty with ``corner``), ``all`` (both) and ``diagonal`` (each
211
+ diagonal cell's hidden ``twin_y``); keys ``(row, column)``. ``plot(i, j)``: a cell."""
212
+
213
+ def __init__(self, figure: Figure, data: Any, names: list[str], corner: bool) -> None:
214
+ self.figure = figure
215
+ self.vars = names
216
+ n = len(names)
217
+ plots: dict[tuple[int, int], Any] = {}
218
+ diag: dict[tuple[int, int], Any] = {}
219
+ for i in range(n):
220
+ for j in range(i + 1 if corner else n):
221
+ p = figure.plot(f"{i},{j}", legend=False if i == j else None)
222
+ p.x_axis(label=names[j])
223
+ p.y_axis(label=names[i])
224
+ plots[i, j] = p
225
+ if i == j:
226
+ t = p.twin_y(legend=False, spines="none")
227
+ t.y_axis(visible=False)
228
+ diag[i, j] = t
229
+ if corner:
230
+ plots[0, 0].y_axis(visible=False)
231
+ self._plots = plots
232
+ lower = {k: p for k, p in plots.items() if k[0] > k[1]}
233
+ upper = {k: p for k, p in plots.items() if k[0] < k[1]}
234
+ self.lower = _Cells(figure, data, lower, names)
235
+ self.upper = _Cells(figure, data, upper, names)
236
+ self.all = _Cells(figure, data, {k: p for k, p in plots.items() if k[0] != k[1]},
237
+ names)
238
+ self.diagonal = _Cells(figure, data, diag, names)
239
+
240
+ def __repr__(self) -> str:
241
+ return f"Pairs({self.vars!r})"
242
+
243
+ def plot(self, i: int, j: int) -> Any:
244
+ """The cell of row ``i`` (y: ``vars[i]``) and column ``j`` (x: ``vars[j]``)."""
245
+ return self._plots[i, j]
246
+
247
+
248
+ def _names(data: Any) -> list[str]:
249
+ if isinstance(data, dict):
250
+ return list(data)
251
+ cols = getattr(data, "columns", None)
252
+ if cols is not None:
253
+ return [str(c) for c in cols]
254
+ names = getattr(getattr(data, "dtype", None), "names", None)
255
+ if names:
256
+ return list(names)
257
+ raise ValueError("pairs needs a dict, a data frame or a structured array")
258
+
259
+
260
+ def pairs(data: Any, vars: Any = None, kind: str = "scatter", diagonal: Any = "auto",
261
+ corner: bool = False, **settings: Any) -> tuple[Figure, Pairs]:
262
+ """Every pair of the columns ``vars`` of ``data`` (default: its numeric columns but
263
+ ``color``'s): an n × n grid, row i plotting ``vars[i]`` on y, column j ``vars[j]`` on
264
+ x, x shared per column and y per row; ``kind`` (``scatter``, ``hexbin``, ``hist2d``,
265
+ ``kde2d``, ``regression``) off the diagonal, ``diagonal`` (``hist``, ``kde``, ``None``;
266
+ ``"auto"``: ``kde`` when ``color`` maps levels, else ``hist``) on it, on a hidden
267
+ ``twin_y`` of each diagonal cell. ``corner=True``: no upper triangle. ``settings`` go
268
+ to the off-diagonal calls (the figure's to the figure); ``color``,
269
+ ``level_order``, ``level_missing`` and (with levels) ``color_map`` to the diagonal too. Returns the figure and its
270
+ :class:`Pairs`.
271
+
272
+ Example::
273
+
274
+ rng = random.Random(1)
275
+ a = [rng.gauss(0, 1) for _ in range(80)]
276
+ data = {"a": a, "b": [v + rng.gauss(0, 0.5) for v in a], "c": [rng.gauss(0, 1) for _ in a]}
277
+ fig, grid = plt.pairs(data, width=90, height=90)
278
+ """
279
+ if kind not in _MAIN:
280
+ raise ValueError(f"kind is one of {', '.join(map(repr, _MAIN))}, got {kind!r}")
281
+ _check("diagonal", diagonal, _DIAGONAL)
282
+ color = settings.get("color")
283
+ if vars is None:
284
+ vars = [c for c in _names(data) if c != color and _numeric(_column(data, c))]
285
+ names = [str(v) for v in vars]
286
+ if not names:
287
+ raise ValueError("pairs: no variables (vars=, or numeric columns in data)")
288
+ fig_kw = _split(kind, settings)
289
+ n = len(names)
290
+ grid = [[f"{i},{j}" if j <= i or not corner else 0 for j in range(n)] for i in range(n)]
291
+ fig_kw.setdefault("share", "grid")
292
+ fig = _figure(grid, fig_kw)
293
+ p = Pairs(fig, data, names, corner)
294
+ shared, levels = _shared(settings, data)
295
+ if n > 1:
296
+ (p.lower if corner else p.all).call(_METHOD.get(kind, kind), (), settings)
297
+ if diagonal == "auto":
298
+ diagonal = "kde" if levels else "hist"
299
+ if diagonal is not None:
300
+ p.diagonal.call(diagonal, (), shared)
301
+ return fig, p
302
+
303
+
304
+ _cell_methods()