plot3 0.4.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.
- plot3/__init__.py +301 -0
- plot3/__version__.py +1 -0
- plot3/aesexpr.py +271 -0
- plot3/build.py +3948 -0
- plot3/calculus.py +1179 -0
- plot3/compose.py +285 -0
- plot3/contour.py +476 -0
- plot3/craft.py +142 -0
- plot3/encode.py +68 -0
- plot3/expr.py +1557 -0
- plot3/flip.py +245 -0
- plot3/function.py +1301 -0
- plot3/geoms.py +2558 -0
- plot3/ggplot.py +713 -0
- plot3/io.py +76 -0
- plot3/jupyter.py +514 -0
- plot3/latexin.py +616 -0
- plot3/masking.py +494 -0
- plot3/mathtext.py +842 -0
- plot3/payload.py +216 -0
- plot3/remote.py +220 -0
- plot3/scales.py +387 -0
- plot3/scaling.py +636 -0
- plot3/special.py +407 -0
- plot3/stat2d.py +1539 -0
- plot3/static.py +3760 -0
- plot3/stats3d.py +462 -0
- plot3/table.py +775 -0
- plot3/themes.py +104 -0
- plot3/viewer.py +3354 -0
- plot3-0.4.0.dist-info/METADATA +504 -0
- plot3-0.4.0.dist-info/RECORD +35 -0
- plot3-0.4.0.dist-info/WHEEL +5 -0
- plot3-0.4.0.dist-info/licenses/LICENSE +21 -0
- plot3-0.4.0.dist-info/top_level.txt +1 -0
plot3/table.py
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
"""Multi-backend table adapters: pandas, polars, tidy3, NumPy arrays.
|
|
2
|
+
|
|
3
|
+
Design rules
|
|
4
|
+
------------
|
|
5
|
+
* Keep the original table type as long as possible (do not normalize at input).
|
|
6
|
+
* Stats / transforms run in the table backend (``count_by``, ``group_keys``, …).
|
|
7
|
+
* Rendering only needs plain arrays: call ``materialize_columns`` /
|
|
8
|
+
``to_arrays`` at the build boundary for the selected columns.
|
|
9
|
+
|
|
10
|
+
Backends
|
|
11
|
+
--------
|
|
12
|
+
* ``"pandas"`` — ``pandas.DataFrame``
|
|
13
|
+
* ``"polars"`` — ``polars.DataFrame`` or ``polars.LazyFrame``
|
|
14
|
+
* ``"tidy"`` — tidy3 ``TidyFrame`` (resolved to polars lazily)
|
|
15
|
+
* ``"array"`` — :class:`ArrayTable` over a 2D NumPy array (columns ``\"0\"``…)
|
|
16
|
+
* plain mappings / records fall back to a pandas DataFrame
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any, Literal
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import pandas as pd
|
|
25
|
+
|
|
26
|
+
Backend = Literal["pandas", "polars", "tidy", "array"]
|
|
27
|
+
|
|
28
|
+
_POLARS_TYPES: tuple[type, ...] | None = None
|
|
29
|
+
_POLARS_READY = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ArrayTable:
|
|
33
|
+
"""Columnar view of a 2D NumPy array for ``ggplot`` / table adapters.
|
|
34
|
+
|
|
35
|
+
Columns are named ``\"0\"``, ``\"1\"``, … so aesthetics can use integer
|
|
36
|
+
positions::
|
|
37
|
+
|
|
38
|
+
ggplot(points, aes(x=0, y=1, z=2, colour=3)) + geom_point3d()
|
|
39
|
+
|
|
40
|
+
1D inputs are expanded by :func:`as_table` to ``[index, values]`` so
|
|
41
|
+
``aes(x=0, y=1)`` plots the series against its row index.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
__slots__ = ("_data",)
|
|
45
|
+
|
|
46
|
+
def __init__(self, data: np.ndarray):
|
|
47
|
+
arr = np.asarray(data)
|
|
48
|
+
if arr.ndim != 2:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"ArrayTable requires a 2D array, got shape {arr.shape}"
|
|
51
|
+
)
|
|
52
|
+
if arr.shape[1] < 1:
|
|
53
|
+
raise ValueError("ArrayTable requires at least one column")
|
|
54
|
+
# Keep a contiguous copy only when needed; do not force float.
|
|
55
|
+
self._data = np.ascontiguousarray(arr)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def values(self) -> np.ndarray:
|
|
59
|
+
return self._data
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def shape(self) -> tuple[int, int]:
|
|
63
|
+
return int(self._data.shape[0]), int(self._data.shape[1])
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def columns(self) -> list[str]:
|
|
67
|
+
return [str(i) for i in range(self._data.shape[1])]
|
|
68
|
+
|
|
69
|
+
def __len__(self) -> int:
|
|
70
|
+
return int(self._data.shape[0])
|
|
71
|
+
|
|
72
|
+
def column(self, name: str | int) -> np.ndarray:
|
|
73
|
+
"""Return a 1D view/copy of column *name* (``\"0\"`` or ``0``)."""
|
|
74
|
+
return self._data[:, _array_col_index(name, self._data.shape[1])]
|
|
75
|
+
|
|
76
|
+
def select(self, cols: list[str]) -> "ArrayTable":
|
|
77
|
+
idxs = [_array_col_index(c, self._data.shape[1]) for c in cols]
|
|
78
|
+
return ArrayTable(self._data[:, idxs])
|
|
79
|
+
|
|
80
|
+
def take_rows(self, indices: np.ndarray) -> "ArrayTable":
|
|
81
|
+
return ArrayTable(self._data[np.asarray(indices, dtype=np.intp)])
|
|
82
|
+
|
|
83
|
+
def subsample(self, step: int) -> "ArrayTable":
|
|
84
|
+
step = max(1, int(step))
|
|
85
|
+
if step == 1:
|
|
86
|
+
return self
|
|
87
|
+
return ArrayTable(self._data[::step])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _array_col_index(name: str | int, n_cols: int) -> int:
|
|
91
|
+
try:
|
|
92
|
+
idx = int(name)
|
|
93
|
+
except (TypeError, ValueError) as e:
|
|
94
|
+
raise KeyError(
|
|
95
|
+
f"array columns are positional (\"0\", \"1\", …); got {name!r}"
|
|
96
|
+
) from e
|
|
97
|
+
if idx < 0 or idx >= n_cols:
|
|
98
|
+
raise KeyError(
|
|
99
|
+
f"column(s) not in DataFrame: {[str(name)]} "
|
|
100
|
+
f"(array has {n_cols} column(s) indexed 0..{n_cols - 1})"
|
|
101
|
+
)
|
|
102
|
+
return idx
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _polars_types() -> tuple[type, ...] | None:
|
|
106
|
+
"""Lazy import of polars frame types (optional dependency)."""
|
|
107
|
+
global _POLARS_TYPES, _POLARS_READY
|
|
108
|
+
if _POLARS_READY:
|
|
109
|
+
return _POLARS_TYPES
|
|
110
|
+
_POLARS_READY = True
|
|
111
|
+
try:
|
|
112
|
+
import polars as pl
|
|
113
|
+
|
|
114
|
+
_POLARS_TYPES = (pl.DataFrame, pl.LazyFrame)
|
|
115
|
+
except ImportError:
|
|
116
|
+
_POLARS_TYPES = None
|
|
117
|
+
return _POLARS_TYPES
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_polars(data: Any) -> bool:
|
|
121
|
+
types = _polars_types()
|
|
122
|
+
return types is not None and isinstance(data, types)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _is_tidy(data: Any) -> bool:
|
|
126
|
+
"""Duck-type tidy3.TidyFrame without importing tidy3."""
|
|
127
|
+
if data is None:
|
|
128
|
+
return False
|
|
129
|
+
cls = type(data)
|
|
130
|
+
mod = getattr(cls, "__module__", "") or ""
|
|
131
|
+
name = cls.__name__
|
|
132
|
+
if name == "TidyFrame" and ("tidy3" in mod or mod == "tidy3"):
|
|
133
|
+
return True
|
|
134
|
+
# Fallback: tidy frames expose backend + to_polars / collect
|
|
135
|
+
if name == "TidyFrame" and hasattr(data, "to_polars") and hasattr(data, "columns"):
|
|
136
|
+
return True
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _is_array_table(data: Any) -> bool:
|
|
141
|
+
return isinstance(data, ArrayTable)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_numpy_array(data: Any) -> bool:
|
|
145
|
+
return isinstance(data, np.ndarray)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def detect_backend(data: Any) -> Backend:
|
|
149
|
+
"""Return the table backend name for *data*."""
|
|
150
|
+
if isinstance(data, pd.DataFrame):
|
|
151
|
+
return "pandas"
|
|
152
|
+
if _is_array_table(data):
|
|
153
|
+
return "array"
|
|
154
|
+
if _is_numpy_array(data):
|
|
155
|
+
# Raw ndarray should have been wrapped by as_table; still detect.
|
|
156
|
+
return "array"
|
|
157
|
+
if _is_polars(data):
|
|
158
|
+
return "polars"
|
|
159
|
+
if _is_tidy(data):
|
|
160
|
+
return "tidy"
|
|
161
|
+
raise TypeError(
|
|
162
|
+
f"unsupported table type {type(data).__name__!r}; "
|
|
163
|
+
"expected pandas DataFrame, polars DataFrame/LazyFrame, "
|
|
164
|
+
"tidy3 TidyFrame, NumPy ndarray, or a mapping/records constructor"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def as_table(data: Any) -> Any:
|
|
169
|
+
"""Keep known table types; wrap arrays; wrap plain constructors as pandas.
|
|
170
|
+
|
|
171
|
+
* pandas / polars / tidy3 / :class:`ArrayTable` — returned unchanged
|
|
172
|
+
(ndarrays are normalized into :class:`ArrayTable`).
|
|
173
|
+
* 1D ndarray — expanded to columns ``0`` = row index, ``1`` = values
|
|
174
|
+
* 2D ndarray — columns ``0`` … ``k-1``
|
|
175
|
+
* mappings / records — ``pandas.DataFrame``
|
|
176
|
+
"""
|
|
177
|
+
if data is None:
|
|
178
|
+
return None
|
|
179
|
+
if isinstance(data, pd.DataFrame):
|
|
180
|
+
return data
|
|
181
|
+
if _is_array_table(data):
|
|
182
|
+
return data
|
|
183
|
+
if _is_numpy_array(data):
|
|
184
|
+
return _normalize_ndarray(data)
|
|
185
|
+
if _is_polars(data) or _is_tidy(data):
|
|
186
|
+
return data
|
|
187
|
+
try:
|
|
188
|
+
detect_backend(data)
|
|
189
|
+
return data
|
|
190
|
+
except TypeError:
|
|
191
|
+
pass
|
|
192
|
+
return pd.DataFrame(data)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _normalize_ndarray(data: np.ndarray) -> ArrayTable:
|
|
196
|
+
arr = np.asarray(data)
|
|
197
|
+
if arr.ndim == 0:
|
|
198
|
+
raise TypeError("ggplot() does not accept 0-D NumPy arrays")
|
|
199
|
+
if arr.ndim == 1:
|
|
200
|
+
n = int(arr.shape[0])
|
|
201
|
+
# index + values → aes(x=0, y=1)
|
|
202
|
+
stacked = np.column_stack(
|
|
203
|
+
[
|
|
204
|
+
np.arange(n, dtype=np.float64),
|
|
205
|
+
np.asarray(arr, dtype=np.float64),
|
|
206
|
+
]
|
|
207
|
+
)
|
|
208
|
+
return ArrayTable(stacked)
|
|
209
|
+
if arr.ndim == 2:
|
|
210
|
+
return ArrayTable(arr)
|
|
211
|
+
raise TypeError(
|
|
212
|
+
f"ggplot() NumPy arrays must be 1D or 2D, got shape {arr.shape}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _as_array_table(data: Any) -> ArrayTable:
|
|
217
|
+
if isinstance(data, ArrayTable):
|
|
218
|
+
return data
|
|
219
|
+
if isinstance(data, np.ndarray):
|
|
220
|
+
return _normalize_ndarray(data)
|
|
221
|
+
raise TypeError(f"expected ArrayTable or ndarray, got {type(data).__name__}")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def get_columns(data: Any) -> list[str]:
|
|
225
|
+
"""Column names as ``list[str]``."""
|
|
226
|
+
backend = detect_backend(data)
|
|
227
|
+
if backend == "pandas":
|
|
228
|
+
return [str(c) for c in data.columns]
|
|
229
|
+
if backend == "array":
|
|
230
|
+
return _as_array_table(data).columns
|
|
231
|
+
if backend == "polars":
|
|
232
|
+
import polars as pl
|
|
233
|
+
|
|
234
|
+
if isinstance(data, pl.LazyFrame):
|
|
235
|
+
return list(data.collect_schema().names())
|
|
236
|
+
return list(data.columns)
|
|
237
|
+
# tidy
|
|
238
|
+
cols = getattr(data, "columns", None)
|
|
239
|
+
if cols is not None:
|
|
240
|
+
return list(cols)
|
|
241
|
+
return list(data.to_polars().columns)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def n_rows(data: Any) -> int:
|
|
245
|
+
"""Number of rows (may materialize a count for lazy frames)."""
|
|
246
|
+
backend = detect_backend(data)
|
|
247
|
+
if backend == "pandas":
|
|
248
|
+
return int(len(data))
|
|
249
|
+
if backend == "array":
|
|
250
|
+
return len(_as_array_table(data))
|
|
251
|
+
if backend == "polars":
|
|
252
|
+
import polars as pl
|
|
253
|
+
|
|
254
|
+
if isinstance(data, pl.LazyFrame):
|
|
255
|
+
return int(data.select(pl.len()).collect().item())
|
|
256
|
+
return int(data.height)
|
|
257
|
+
# tidy
|
|
258
|
+
return int(len(data))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class ColumnNotFound(KeyError):
|
|
262
|
+
"""A column named in ``aes()`` (or a facet) that the data does not have.
|
|
263
|
+
|
|
264
|
+
The message lists the data's columns and the nearest match, so a typo
|
|
265
|
+
reads as one.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def __init__(self, missing, data: Any = None):
|
|
269
|
+
import difflib
|
|
270
|
+
|
|
271
|
+
self.missing = [str(m) for m in missing]
|
|
272
|
+
try:
|
|
273
|
+
names = [str(c) for c in get_columns(data)] if data is not None else []
|
|
274
|
+
except Exception:
|
|
275
|
+
names = []
|
|
276
|
+
parts = []
|
|
277
|
+
for name in self.missing:
|
|
278
|
+
text = f"No column {name!r} in the data."
|
|
279
|
+
guess = difflib.get_close_matches(name, names, n=1)
|
|
280
|
+
if guess:
|
|
281
|
+
text += f" Did you mean {guess[0]!r}?"
|
|
282
|
+
elif any(ch in name for ch in " +-*/()^<>=!"):
|
|
283
|
+
text += (
|
|
284
|
+
" As an expression, aes() reads columns, numbers, + - * / ^, "
|
|
285
|
+
"comparisons, and log, log10, log2, exp, sqrt, abs, round, "
|
|
286
|
+
"floor, ceiling, factor, as.numeric, ifelse, pmin, pmax, "
|
|
287
|
+
"mean, median, sd, min, max, sum."
|
|
288
|
+
)
|
|
289
|
+
parts.append(text)
|
|
290
|
+
if names:
|
|
291
|
+
shown = ", ".join(names[:20]) + (", …" if len(names) > 20 else "")
|
|
292
|
+
parts.append(f"The columns are: {shown}.")
|
|
293
|
+
super().__init__(" ".join(parts))
|
|
294
|
+
|
|
295
|
+
def __str__(self) -> str:
|
|
296
|
+
return str(self.args[0])
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def has_column(data: Any, name: str) -> bool:
|
|
300
|
+
name = str(name)
|
|
301
|
+
if detect_backend(data) == "array":
|
|
302
|
+
try:
|
|
303
|
+
_array_col_index(name, _as_array_table(data).shape[1])
|
|
304
|
+
return True
|
|
305
|
+
except KeyError:
|
|
306
|
+
return False
|
|
307
|
+
return name in get_columns(data)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def resolve_polars(data: Any):
|
|
311
|
+
"""Materialize tidy / LazyFrame to an eager polars DataFrame."""
|
|
312
|
+
import polars as pl
|
|
313
|
+
|
|
314
|
+
backend = detect_backend(data)
|
|
315
|
+
if backend == "tidy":
|
|
316
|
+
to_polars = getattr(data, "to_polars", None)
|
|
317
|
+
if callable(to_polars):
|
|
318
|
+
return to_polars()
|
|
319
|
+
collect = getattr(data, "collect", None)
|
|
320
|
+
if callable(collect):
|
|
321
|
+
out = collect(as_="polars")
|
|
322
|
+
if isinstance(out, pl.DataFrame):
|
|
323
|
+
return out
|
|
324
|
+
raise TypeError("tidy frame could not be resolved to polars")
|
|
325
|
+
if backend == "polars":
|
|
326
|
+
if isinstance(data, pl.LazyFrame):
|
|
327
|
+
return data.collect()
|
|
328
|
+
return data
|
|
329
|
+
raise TypeError("resolve_polars() expects a polars or tidy table")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _polars_to_pandas(frame) -> pd.DataFrame:
|
|
333
|
+
"""Convert a polars DataFrame without requiring pyarrow."""
|
|
334
|
+
try:
|
|
335
|
+
return frame.to_pandas()
|
|
336
|
+
except ModuleNotFoundError:
|
|
337
|
+
# pyarrow missing — build column-by-column from Python/NumPy values
|
|
338
|
+
data = {}
|
|
339
|
+
for name in frame.columns:
|
|
340
|
+
series = frame.get_column(name)
|
|
341
|
+
try:
|
|
342
|
+
data[name] = series.to_numpy(allow_copy=True)
|
|
343
|
+
except Exception:
|
|
344
|
+
data[name] = series.to_list()
|
|
345
|
+
return pd.DataFrame(data)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def as_pandas(data: Any) -> pd.DataFrame:
|
|
349
|
+
"""Escape hatch: full conversion to pandas (for non-migrated stats)."""
|
|
350
|
+
backend = detect_backend(data)
|
|
351
|
+
if backend == "pandas":
|
|
352
|
+
return data
|
|
353
|
+
if backend == "array":
|
|
354
|
+
at = _as_array_table(data)
|
|
355
|
+
return pd.DataFrame(
|
|
356
|
+
{c: at.column(c) for c in at.columns},
|
|
357
|
+
copy=False,
|
|
358
|
+
)
|
|
359
|
+
if backend == "polars":
|
|
360
|
+
return _polars_to_pandas(resolve_polars(data))
|
|
361
|
+
# tidy
|
|
362
|
+
to_pandas = getattr(data, "to_pandas", None)
|
|
363
|
+
if callable(to_pandas):
|
|
364
|
+
try:
|
|
365
|
+
out = to_pandas()
|
|
366
|
+
if isinstance(out, pd.DataFrame):
|
|
367
|
+
return out
|
|
368
|
+
except ModuleNotFoundError:
|
|
369
|
+
pass
|
|
370
|
+
return _polars_to_pandas(resolve_polars(data))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def select_cols(data: Any, cols: list[str]):
|
|
374
|
+
"""Project columns; return same backend type when possible."""
|
|
375
|
+
cols = [str(c) for c in cols]
|
|
376
|
+
missing = [c for c in cols if not has_column(data, c)]
|
|
377
|
+
if missing:
|
|
378
|
+
raise ColumnNotFound(missing, data)
|
|
379
|
+
backend = detect_backend(data)
|
|
380
|
+
if backend == "pandas":
|
|
381
|
+
return data.loc[:, list(cols)]
|
|
382
|
+
if backend == "array":
|
|
383
|
+
return _as_array_table(data).select(cols)
|
|
384
|
+
if backend == "polars":
|
|
385
|
+
import polars as pl
|
|
386
|
+
|
|
387
|
+
if isinstance(data, pl.LazyFrame):
|
|
388
|
+
return data.select(cols)
|
|
389
|
+
return data.select(cols)
|
|
390
|
+
# tidy — project via polars materialization of selected columns
|
|
391
|
+
to_polars = getattr(data, "to_polars", None)
|
|
392
|
+
if callable(to_polars):
|
|
393
|
+
try:
|
|
394
|
+
return to_polars(columns=cols)
|
|
395
|
+
except TypeError:
|
|
396
|
+
return resolve_polars(data).select(cols)
|
|
397
|
+
return resolve_polars(data).select(cols)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def subsample_rows(data: Any, step: int):
|
|
401
|
+
"""Deterministic row stride (for ``coord_3d(max_points=…)``)."""
|
|
402
|
+
step = max(1, int(step))
|
|
403
|
+
if step == 1:
|
|
404
|
+
return data
|
|
405
|
+
backend = detect_backend(data)
|
|
406
|
+
if backend == "pandas":
|
|
407
|
+
return data.iloc[::step].copy()
|
|
408
|
+
if backend == "array":
|
|
409
|
+
return _as_array_table(data).subsample(step)
|
|
410
|
+
if backend == "polars":
|
|
411
|
+
import polars as pl
|
|
412
|
+
|
|
413
|
+
frame = resolve_polars(data)
|
|
414
|
+
return frame.with_row_index("_plot3_i").filter(
|
|
415
|
+
(pl.col("_plot3_i") % step) == 0
|
|
416
|
+
).drop("_plot3_i")
|
|
417
|
+
# tidy → polars subsample (keeps plot path on polars)
|
|
418
|
+
frame = resolve_polars(data)
|
|
419
|
+
import polars as pl
|
|
420
|
+
|
|
421
|
+
return frame.with_row_index("_plot3_i").filter(
|
|
422
|
+
(pl.col("_plot3_i") % step) == 0
|
|
423
|
+
).drop("_plot3_i")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def count_by(data: Any, x: str):
|
|
427
|
+
"""Count rows by discrete *x*; return a table with columns ``[x, "y"]``.
|
|
428
|
+
|
|
429
|
+
* pandas → ``groupby(...).size()``
|
|
430
|
+
* polars → ``group_by(...).len()``
|
|
431
|
+
* array → NumPy unique + counts (order of first appearance)
|
|
432
|
+
* tidy → collect to polars, then the polars path
|
|
433
|
+
"""
|
|
434
|
+
x = str(x)
|
|
435
|
+
if not has_column(data, x):
|
|
436
|
+
raise ColumnNotFound([x], data)
|
|
437
|
+
backend = detect_backend(data)
|
|
438
|
+
if backend == "pandas":
|
|
439
|
+
return (
|
|
440
|
+
data.groupby(x, dropna=False, observed=True, sort=False)
|
|
441
|
+
.size()
|
|
442
|
+
.rename("y")
|
|
443
|
+
.reset_index()
|
|
444
|
+
)
|
|
445
|
+
if backend == "array":
|
|
446
|
+
col = _as_array_table(data).column(x)
|
|
447
|
+
# Preserve first-appearance order (like polars maintain_order).
|
|
448
|
+
# Convert to list of hashables for unique.
|
|
449
|
+
if col.dtype.kind in "fc":
|
|
450
|
+
# float: use object round-trip carefully; keep raw values
|
|
451
|
+
keys: list[Any] = []
|
|
452
|
+
counts: list[int] = []
|
|
453
|
+
index: dict[Any, int] = {}
|
|
454
|
+
for v in col.tolist():
|
|
455
|
+
# NaN is not equal to itself
|
|
456
|
+
if isinstance(v, float) and np.isnan(v):
|
|
457
|
+
key = ("__nan__",)
|
|
458
|
+
else:
|
|
459
|
+
key = v
|
|
460
|
+
if key not in index:
|
|
461
|
+
index[key] = len(keys)
|
|
462
|
+
keys.append(v)
|
|
463
|
+
counts.append(0)
|
|
464
|
+
counts[index[key]] += 1
|
|
465
|
+
return pd.DataFrame({x: keys, "y": counts})
|
|
466
|
+
uniq, inv = np.unique(col, return_inverse=True)
|
|
467
|
+
# np.unique sorts — rebuild appearance order
|
|
468
|
+
first = {}
|
|
469
|
+
order = []
|
|
470
|
+
for i, v in enumerate(col.tolist()):
|
|
471
|
+
if v not in first:
|
|
472
|
+
first[v] = i
|
|
473
|
+
order.append(v)
|
|
474
|
+
count_map = {u: int(c) for u, c in zip(uniq.tolist(), np.bincount(inv))}
|
|
475
|
+
return pd.DataFrame({x: order, "y": [count_map[v] for v in order]})
|
|
476
|
+
|
|
477
|
+
import polars as pl
|
|
478
|
+
|
|
479
|
+
if backend == "tidy":
|
|
480
|
+
frame = resolve_polars(data)
|
|
481
|
+
elif isinstance(data, pl.LazyFrame):
|
|
482
|
+
frame = data.collect()
|
|
483
|
+
else:
|
|
484
|
+
frame = data
|
|
485
|
+
|
|
486
|
+
return (
|
|
487
|
+
frame.group_by(x, maintain_order=True)
|
|
488
|
+
.len()
|
|
489
|
+
.rename({"len": "y"})
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def materialize_columns(data: Any, cols: list[str]) -> pd.DataFrame:
|
|
494
|
+
"""Select *cols*, drop rows with any NA, return a pandas frame.
|
|
495
|
+
|
|
496
|
+
This is the **render boundary**: only the columns needed for encoding
|
|
497
|
+
are converted. The original plot data stays in its native backend.
|
|
498
|
+
"""
|
|
499
|
+
cols = list(dict.fromkeys(str(c) for c in cols))
|
|
500
|
+
missing = [c for c in cols if not has_column(data, c)]
|
|
501
|
+
if missing:
|
|
502
|
+
raise ColumnNotFound(missing, data)
|
|
503
|
+
backend = detect_backend(data)
|
|
504
|
+
if backend == "pandas":
|
|
505
|
+
return data.loc[:, cols].dropna()
|
|
506
|
+
if backend == "array":
|
|
507
|
+
at = _as_array_table(data)
|
|
508
|
+
frame = pd.DataFrame({c: at.column(c) for c in cols}, copy=False)
|
|
509
|
+
return frame.dropna()
|
|
510
|
+
import polars as pl
|
|
511
|
+
|
|
512
|
+
if backend == "tidy":
|
|
513
|
+
frame = resolve_polars(data).select(cols)
|
|
514
|
+
elif isinstance(data, pl.LazyFrame):
|
|
515
|
+
frame = data.select(cols).collect()
|
|
516
|
+
else:
|
|
517
|
+
frame = data.select(cols)
|
|
518
|
+
# drop_nulls on any of the selected columns
|
|
519
|
+
frame = frame.drop_nulls(subset=cols)
|
|
520
|
+
return _polars_to_pandas(frame)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def to_arrays(data: Any, cols: list[str]) -> dict[str, np.ndarray]:
|
|
524
|
+
"""Extract selected columns as NumPy arrays (after dropna on those cols)."""
|
|
525
|
+
frame = materialize_columns(data, cols)
|
|
526
|
+
out: dict[str, np.ndarray] = {}
|
|
527
|
+
for c in cols:
|
|
528
|
+
s = frame[c]
|
|
529
|
+
if pd.api.types.is_numeric_dtype(s):
|
|
530
|
+
out[c] = s.to_numpy(dtype=np.float64, copy=False)
|
|
531
|
+
else:
|
|
532
|
+
out[c] = s.to_numpy(copy=False)
|
|
533
|
+
return out
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def unique_levels(data: Any, col: str) -> list[Any]:
|
|
537
|
+
"""Discrete levels in order of appearance (for facets / violin)."""
|
|
538
|
+
col = str(col)
|
|
539
|
+
if not has_column(data, col):
|
|
540
|
+
raise ColumnNotFound([col], data)
|
|
541
|
+
backend = detect_backend(data)
|
|
542
|
+
if backend == "pandas":
|
|
543
|
+
s = data[col]
|
|
544
|
+
if isinstance(s.dtype, pd.CategoricalDtype):
|
|
545
|
+
levels = [c for c in s.cat.categories if (s == c).any()]
|
|
546
|
+
if s.isna().any():
|
|
547
|
+
levels = list(levels) + [pd.NA]
|
|
548
|
+
return list(levels)
|
|
549
|
+
return list(dict.fromkeys(s.tolist()))
|
|
550
|
+
if backend == "array":
|
|
551
|
+
series = _as_array_table(data).column(col)
|
|
552
|
+
seen: list[Any] = []
|
|
553
|
+
for v in series.tolist():
|
|
554
|
+
if isinstance(v, float) and np.isnan(v):
|
|
555
|
+
if not any(isinstance(x, float) and np.isnan(x) for x in seen):
|
|
556
|
+
seen.append(v)
|
|
557
|
+
elif v not in seen:
|
|
558
|
+
seen.append(v)
|
|
559
|
+
return seen
|
|
560
|
+
import polars as pl
|
|
561
|
+
|
|
562
|
+
if backend == "tidy":
|
|
563
|
+
frame = resolve_polars(data)
|
|
564
|
+
elif isinstance(data, pl.LazyFrame):
|
|
565
|
+
frame = data.collect()
|
|
566
|
+
else:
|
|
567
|
+
frame = data
|
|
568
|
+
|
|
569
|
+
series = frame.get_column(col)
|
|
570
|
+
# preserve appearance order
|
|
571
|
+
seen = []
|
|
572
|
+
for v in series.to_list():
|
|
573
|
+
if v not in seen:
|
|
574
|
+
if v is None:
|
|
575
|
+
if None not in seen:
|
|
576
|
+
seen.append(None)
|
|
577
|
+
else:
|
|
578
|
+
seen.append(v)
|
|
579
|
+
return seen
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def filter_equal(data: Any, col: str, value: Any):
|
|
583
|
+
"""Rows where ``col == value`` (``value is None`` → nulls). Same backend."""
|
|
584
|
+
col = str(col)
|
|
585
|
+
backend = detect_backend(data)
|
|
586
|
+
if backend == "pandas":
|
|
587
|
+
if value is None or (isinstance(value, float) and np.isnan(value)) or pd.isna(value):
|
|
588
|
+
return data.loc[data[col].isna()].copy()
|
|
589
|
+
return data.loc[data[col] == value].copy()
|
|
590
|
+
if backend == "array":
|
|
591
|
+
at = _as_array_table(data)
|
|
592
|
+
series = at.column(col)
|
|
593
|
+
if value is None or (isinstance(value, float) and np.isnan(value)):
|
|
594
|
+
mask = ~np.isfinite(series) if series.dtype.kind == "f" else np.zeros(len(at), dtype=bool)
|
|
595
|
+
if series.dtype.kind == "f":
|
|
596
|
+
mask = np.isnan(series.astype(np.float64, copy=False))
|
|
597
|
+
else:
|
|
598
|
+
mask = series == value
|
|
599
|
+
return at.take_rows(np.flatnonzero(mask))
|
|
600
|
+
import polars as pl
|
|
601
|
+
|
|
602
|
+
if backend == "tidy":
|
|
603
|
+
frame = resolve_polars(data)
|
|
604
|
+
elif isinstance(data, pl.LazyFrame):
|
|
605
|
+
frame = data.collect()
|
|
606
|
+
else:
|
|
607
|
+
frame = data
|
|
608
|
+
if value is None:
|
|
609
|
+
return frame.filter(pl.col(col).is_null())
|
|
610
|
+
return frame.filter(pl.col(col) == value)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _eager_polars(data: Any):
|
|
614
|
+
"""Eager polars DataFrame from polars or tidy input."""
|
|
615
|
+
import polars as pl
|
|
616
|
+
|
|
617
|
+
backend = detect_backend(data)
|
|
618
|
+
if backend == "tidy":
|
|
619
|
+
return resolve_polars(data)
|
|
620
|
+
if isinstance(data, pl.LazyFrame):
|
|
621
|
+
return data.collect()
|
|
622
|
+
return data
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def numeric_array(data: Any, col: str, *, dropna: bool = True) -> np.ndarray:
|
|
626
|
+
"""Extract *col* as ``float64`` (non-numeric → NaN).
|
|
627
|
+
|
|
628
|
+
This is the preferred path for stats that operate on plain arrays
|
|
629
|
+
(histogram, KDE, isosurface samples) without converting the whole table.
|
|
630
|
+
"""
|
|
631
|
+
col = str(col)
|
|
632
|
+
if not has_column(data, col):
|
|
633
|
+
raise ColumnNotFound([col], data)
|
|
634
|
+
backend = detect_backend(data)
|
|
635
|
+
if backend == "pandas":
|
|
636
|
+
arr = pd.to_numeric(data[col], errors="coerce").to_numpy(dtype=np.float64)
|
|
637
|
+
elif backend == "array":
|
|
638
|
+
raw = _as_array_table(data).column(col)
|
|
639
|
+
if raw.dtype.kind in "biufc":
|
|
640
|
+
arr = np.asarray(raw, dtype=np.float64)
|
|
641
|
+
else:
|
|
642
|
+
arr = pd.to_numeric(pd.Series(raw), errors="coerce").to_numpy(
|
|
643
|
+
dtype=np.float64
|
|
644
|
+
)
|
|
645
|
+
else:
|
|
646
|
+
import polars as pl
|
|
647
|
+
|
|
648
|
+
frame = _eager_polars(data)
|
|
649
|
+
series = frame.get_column(col)
|
|
650
|
+
# Cast loosely to float; strings / mixed become null.
|
|
651
|
+
try:
|
|
652
|
+
series = series.cast(pl.Float64, strict=False)
|
|
653
|
+
except Exception:
|
|
654
|
+
vals = []
|
|
655
|
+
for v in series.to_list():
|
|
656
|
+
try:
|
|
657
|
+
vals.append(float(v) if v is not None else np.nan)
|
|
658
|
+
except (TypeError, ValueError):
|
|
659
|
+
vals.append(np.nan)
|
|
660
|
+
arr = np.asarray(vals, dtype=np.float64)
|
|
661
|
+
else:
|
|
662
|
+
arr = series.to_numpy()
|
|
663
|
+
if arr.dtype != np.float64:
|
|
664
|
+
arr = np.asarray(arr, dtype=np.float64)
|
|
665
|
+
# polars may yield object array for some null encodings
|
|
666
|
+
if arr.dtype == object:
|
|
667
|
+
arr = pd.to_numeric(pd.Series(arr), errors="coerce").to_numpy(
|
|
668
|
+
dtype=np.float64
|
|
669
|
+
)
|
|
670
|
+
if dropna:
|
|
671
|
+
return arr[np.isfinite(arr)]
|
|
672
|
+
return arr
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def group_pieces(
|
|
676
|
+
data: Any, group_cols: list[str]
|
|
677
|
+
) -> list[tuple[tuple[Any, ...], Any]]:
|
|
678
|
+
"""Split *data* by *group_cols* in order of first appearance.
|
|
679
|
+
|
|
680
|
+
Returns ``[(key_tuple, subgroup), ...]``. Subgroups keep the pandas
|
|
681
|
+
backend when the source is pandas; polars/tidy sources yield polars
|
|
682
|
+
``DataFrame`` pieces (tidy is resolved first); arrays yield ArrayTable.
|
|
683
|
+
"""
|
|
684
|
+
group_cols = [str(c) for c in group_cols]
|
|
685
|
+
if not group_cols:
|
|
686
|
+
return [((), data)]
|
|
687
|
+
missing = [c for c in group_cols if not has_column(data, c)]
|
|
688
|
+
if missing:
|
|
689
|
+
raise ColumnNotFound(missing, data)
|
|
690
|
+
backend = detect_backend(data)
|
|
691
|
+
if backend == "pandas":
|
|
692
|
+
out: list[tuple[tuple[Any, ...], Any]] = []
|
|
693
|
+
for key, piece in data.groupby(
|
|
694
|
+
group_cols, dropna=False, observed=True, sort=False
|
|
695
|
+
):
|
|
696
|
+
key_tuple = key if isinstance(key, tuple) else (key,)
|
|
697
|
+
out.append((tuple(key_tuple), piece))
|
|
698
|
+
return out
|
|
699
|
+
if backend == "array":
|
|
700
|
+
at = _as_array_table(data)
|
|
701
|
+
# Build group keys from selected columns; preserve appearance order.
|
|
702
|
+
n = len(at)
|
|
703
|
+
if n == 0:
|
|
704
|
+
return []
|
|
705
|
+
key_cols = [at.column(c) for c in group_cols]
|
|
706
|
+
keys_list: list[tuple[Any, ...]] = []
|
|
707
|
+
buckets: dict[tuple[Any, ...], list[int]] = {}
|
|
708
|
+
for i in range(n):
|
|
709
|
+
parts = []
|
|
710
|
+
for col in key_cols:
|
|
711
|
+
v = col[i]
|
|
712
|
+
if isinstance(v, (float, np.floating)) and np.isnan(v):
|
|
713
|
+
parts.append(None)
|
|
714
|
+
else:
|
|
715
|
+
# unbox numpy scalars
|
|
716
|
+
parts.append(v.item() if isinstance(v, np.generic) else v)
|
|
717
|
+
key_t = tuple(parts)
|
|
718
|
+
if key_t not in buckets:
|
|
719
|
+
buckets[key_t] = []
|
|
720
|
+
keys_list.append(key_t)
|
|
721
|
+
buckets[key_t].append(i)
|
|
722
|
+
return [
|
|
723
|
+
(key, at.take_rows(np.asarray(buckets[key], dtype=np.intp)))
|
|
724
|
+
for key in keys_list
|
|
725
|
+
]
|
|
726
|
+
|
|
727
|
+
frame = _eager_polars(data)
|
|
728
|
+
out = []
|
|
729
|
+
for key, piece in frame.group_by(group_cols, maintain_order=True):
|
|
730
|
+
if not isinstance(key, tuple):
|
|
731
|
+
key_tuple = (key,)
|
|
732
|
+
else:
|
|
733
|
+
key_tuple = tuple(key)
|
|
734
|
+
out.append((key_tuple, piece))
|
|
735
|
+
return out
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def category_labels(data: Any, col: str) -> list[str]:
|
|
739
|
+
"""Stable category labels for a discrete column (violin x, etc.).
|
|
740
|
+
|
|
741
|
+
Prefer categorical dtype ordering when present; otherwise first-appearance
|
|
742
|
+
order of stringified values.
|
|
743
|
+
"""
|
|
744
|
+
col = str(col)
|
|
745
|
+
if not has_column(data, col):
|
|
746
|
+
raise ColumnNotFound([col], data)
|
|
747
|
+
backend = detect_backend(data)
|
|
748
|
+
from plot3.scales import ordered_levels
|
|
749
|
+
|
|
750
|
+
if backend == "pandas":
|
|
751
|
+
s = data[col]
|
|
752
|
+
if isinstance(s.dtype, pd.CategoricalDtype):
|
|
753
|
+
return [str(c) for c in s.cat.categories]
|
|
754
|
+
return ["None" if v is None else str(v) for v in ordered_levels(s.tolist())]
|
|
755
|
+
if backend == "array":
|
|
756
|
+
values = _as_array_table(data).column(col).tolist()
|
|
757
|
+
return ["None" if v is None else str(v) for v in ordered_levels(values)]
|
|
758
|
+
|
|
759
|
+
import polars as pl
|
|
760
|
+
|
|
761
|
+
frame = _eager_polars(data)
|
|
762
|
+
series = frame.get_column(col)
|
|
763
|
+
dtype = series.dtype
|
|
764
|
+
if dtype == pl.Categorical or dtype == pl.Enum:
|
|
765
|
+
cats = series.cat.get_categories().to_list()
|
|
766
|
+
return [str(c) for c in cats]
|
|
767
|
+
return ["None" if v is None else str(v) for v in ordered_levels(series.to_list())]
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def require_columns(data: Any, cols: list[str]) -> None:
|
|
771
|
+
"""Raise ``KeyError`` if any of *cols* is missing."""
|
|
772
|
+
cols = [str(c) for c in cols]
|
|
773
|
+
missing = [c for c in cols if not has_column(data, c)]
|
|
774
|
+
if missing:
|
|
775
|
+
raise ColumnNotFound(missing, data)
|