data-sampler 3.2.1__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.
- data_sampler/__init__.py +82 -0
- data_sampler/__main__.py +7 -0
- data_sampler/_logging.py +61 -0
- data_sampler/_names.py +60 -0
- data_sampler/anonymize.py +463 -0
- data_sampler/cli.py +363 -0
- data_sampler/engine.py +628 -0
- data_sampler/io.py +75 -0
- data_sampler/report.py +289 -0
- data_sampler/sampling.py +193 -0
- data_sampler/stats.py +174 -0
- data_sampler/tui/__init__.py +16 -0
- data_sampler/tui/app.py +904 -0
- data_sampler/workflow.py +285 -0
- data_sampler-3.2.1.dist-info/METADATA +427 -0
- data_sampler-3.2.1.dist-info/RECORD +19 -0
- data_sampler-3.2.1.dist-info/WHEEL +4 -0
- data_sampler-3.2.1.dist-info/entry_points.txt +3 -0
- data_sampler-3.2.1.dist-info/licenses/LICENSE +21 -0
data_sampler/engine.py
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
"""DuckDB-backed out-of-core engine for large inputs.
|
|
2
|
+
|
|
3
|
+
Optional — install the extra: ``pip install "data-sampler[large]"``.
|
|
4
|
+
|
|
5
|
+
The pandas path (:mod:`data_sampler.sampling`) loads the whole file into memory
|
|
6
|
+
and is the default for small/medium data. This engine instead pushes loading,
|
|
7
|
+
stratification, and sampling **into DuckDB**: vectorized, multi-threaded, and
|
|
8
|
+
able to spill to disk, so inputs far larger than RAM can be sampled without ever
|
|
9
|
+
materializing them in pandas. Only the resulting sample (``count`` rows) comes
|
|
10
|
+
back as a DataFrame.
|
|
11
|
+
|
|
12
|
+
Highlights:
|
|
13
|
+
|
|
14
|
+
- **Parallelism** — ``PRAGMA threads`` uses all cores by default.
|
|
15
|
+
- **Out-of-core** — a memory limit plus a temp directory let DuckDB spill.
|
|
16
|
+
- **Native readers** — CSV/TSV/JSON and, especially, Parquet are read directly
|
|
17
|
+
with projection pushdown (only the needed columns are scanned).
|
|
18
|
+
- **Streaming sampling** — reservoir sampling for the random case (exact count,
|
|
19
|
+
single pass) and window-based proportional sampling for the stratified case.
|
|
20
|
+
|
|
21
|
+
This module imports :mod:`duckdb` lazily, so importing it never fails just
|
|
22
|
+
because the optional dependency is absent — only *using* the engine does, with a
|
|
23
|
+
clear message.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import tempfile
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Iterable
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
import pandas as pd
|
|
35
|
+
|
|
36
|
+
from ._logging import get_logger
|
|
37
|
+
from .sampling import SampleResult
|
|
38
|
+
from .stats import ColumnStats, _fmt_num
|
|
39
|
+
|
|
40
|
+
log = get_logger(__name__)
|
|
41
|
+
|
|
42
|
+
# Above this row count the engine is a good idea and materializing the whole
|
|
43
|
+
# thing in pandas risks exhausting memory (used for auto-selection + warnings).
|
|
44
|
+
LARGE_ROW_THRESHOLD = 5_000_000
|
|
45
|
+
|
|
46
|
+
# extensions DuckDB can read natively (no pandas round-trip)
|
|
47
|
+
_NATIVE_READERS = (".csv", ".tsv", ".json", ".parquet")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DuckDBUnavailable(RuntimeError):
|
|
51
|
+
"""Raised when the DuckDB engine is used without the optional dependency."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _require_duckdb():
|
|
55
|
+
try:
|
|
56
|
+
import duckdb # noqa: PLC0415
|
|
57
|
+
except ImportError as exc: # pragma: no cover - exercised without the extra
|
|
58
|
+
raise DuckDBUnavailable(
|
|
59
|
+
"The DuckDB engine needs the optional 'large' extra. Install it with:"
|
|
60
|
+
" pip install \"data-sampler[large]\""
|
|
61
|
+
) from exc
|
|
62
|
+
return duckdb
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _quote_ident(name: str) -> str:
|
|
66
|
+
"""Quote a SQL identifier (column name), escaping embedded quotes."""
|
|
67
|
+
return '"' + str(name).replace('"', '""') + '"'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _quote_str(value: str) -> str:
|
|
71
|
+
return "'" + str(value).replace("'", "''") + "'"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _to_float(v) -> float | None:
|
|
75
|
+
"""Coerce a DuckDB scalar to a finite float, or None (NULL/NaN/inf)."""
|
|
76
|
+
if v is None:
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
f = float(v)
|
|
80
|
+
except (TypeError, ValueError):
|
|
81
|
+
return None
|
|
82
|
+
return f if np.isfinite(f) else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _duckdb_kind(dtype: str) -> str:
|
|
86
|
+
"""Map a DuckDB column type name to a :class:`ColumnStats` kind."""
|
|
87
|
+
d = dtype.lower()
|
|
88
|
+
# nested/wrapped types first: INTEGER[], DECIMAL(6,2)[], STRUCT(...), MAP(...)
|
|
89
|
+
# would otherwise match the scalar numeric prefixes and crash the numeric
|
|
90
|
+
# aggregates ("Unimplemented type for cast (INTEGER[] -> DOUBLE)")
|
|
91
|
+
if d.endswith("]") or d.startswith(("struct", "map", "list", "union", "array", "json")):
|
|
92
|
+
return "other"
|
|
93
|
+
if d.startswith("bool"):
|
|
94
|
+
return "boolean"
|
|
95
|
+
if d.startswith(("timestamp", "date")):
|
|
96
|
+
return "datetime"
|
|
97
|
+
# TIME (no date part) and INTERVAL (timedelta) cannot be datetime-jittered
|
|
98
|
+
# or averaged; "interval" must also never match the "int" numeric prefix
|
|
99
|
+
if d.startswith(("time", "interval")):
|
|
100
|
+
return "other"
|
|
101
|
+
if d.startswith((
|
|
102
|
+
"tinyint", "smallint", "integer", "int", "bigint", "hugeint",
|
|
103
|
+
"utinyint", "usmallint", "uinteger", "ubigint", "uhugeint",
|
|
104
|
+
"decimal", "double", "float", "real", "numeric",
|
|
105
|
+
)):
|
|
106
|
+
return "numeric"
|
|
107
|
+
if d.startswith(("varchar", "char", "text", "string", "uuid", "bit", "blob", "enum")):
|
|
108
|
+
return "categorical" # refined to "text" by average length
|
|
109
|
+
return "other"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _proportional_allocation(sizes: np.ndarray, count: int) -> np.ndarray:
|
|
113
|
+
"""Largest-remainder proportional allocation summing exactly to ``count``.
|
|
114
|
+
|
|
115
|
+
Mirrors :func:`data_sampler.sampling.stratified_sample` so the DuckDB path
|
|
116
|
+
allocates strata identically to the pandas path.
|
|
117
|
+
"""
|
|
118
|
+
sizes = np.asarray(sizes, dtype=float)
|
|
119
|
+
total = sizes.sum()
|
|
120
|
+
if total <= 0:
|
|
121
|
+
return np.zeros(len(sizes), dtype=np.int64)
|
|
122
|
+
exact = sizes / total * count
|
|
123
|
+
alloc = np.floor(exact).astype(np.int64)
|
|
124
|
+
shortfall = int(count - alloc.sum())
|
|
125
|
+
if shortfall > 0:
|
|
126
|
+
# hand the remaining slots to the largest fractional remainders
|
|
127
|
+
order = np.argsort(-(exact - alloc), kind="stable")
|
|
128
|
+
alloc[order[:shortfall]] += 1
|
|
129
|
+
# never allocate a stratum more rows than it has
|
|
130
|
+
return np.minimum(alloc, sizes.astype(np.int64))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class DuckDBEngine:
|
|
134
|
+
"""A configured DuckDB connection with sampling/stats helpers.
|
|
135
|
+
|
|
136
|
+
``threads`` defaults to all CPU cores; ``memory_limit`` (e.g. ``"8GB"``)
|
|
137
|
+
caps RAM so DuckDB spills to ``temp_directory`` instead of OOM-ing.
|
|
138
|
+
Use as a context manager to close the connection.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
threads: int | None = None,
|
|
144
|
+
memory_limit: str | None = None,
|
|
145
|
+
temp_directory: str | None = None,
|
|
146
|
+
):
|
|
147
|
+
duckdb = _require_duckdb()
|
|
148
|
+
self.con = duckdb.connect(":memory:")
|
|
149
|
+
self.threads = int(threads) if threads else (os.cpu_count() or 4)
|
|
150
|
+
self.con.execute(f"PRAGMA threads={self.threads}")
|
|
151
|
+
if memory_limit:
|
|
152
|
+
self.con.execute(f"PRAGMA memory_limit={_quote_str(memory_limit)}")
|
|
153
|
+
self.con.execute(
|
|
154
|
+
f"PRAGMA temp_directory={_quote_str(temp_directory or tempfile.gettempdir())}"
|
|
155
|
+
)
|
|
156
|
+
self._reg = 0
|
|
157
|
+
# per-source row-count cache: one engine session assumes its file
|
|
158
|
+
# sources do not change underneath it, so count(*) runs once per file
|
|
159
|
+
# instead of once per operation (row_count/sample/strat-selection/stats)
|
|
160
|
+
self._count_cache: dict[str, int] = {}
|
|
161
|
+
log.info("DuckDBEngine ready (threads=%d, memory_limit=%s)", self.threads, memory_limit)
|
|
162
|
+
|
|
163
|
+
# ── lifecycle ─────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def close(self) -> None:
|
|
166
|
+
self.con.close()
|
|
167
|
+
|
|
168
|
+
def __enter__(self) -> "DuckDBEngine":
|
|
169
|
+
return self
|
|
170
|
+
|
|
171
|
+
def __exit__(self, *exc) -> None:
|
|
172
|
+
self.close()
|
|
173
|
+
|
|
174
|
+
# ── source resolution ─────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
def _source_sql(self, source) -> str:
|
|
177
|
+
"""Return a SQL table expression for ``source``.
|
|
178
|
+
|
|
179
|
+
``source`` may be a path (CSV/TSV/JSON/Parquet, read natively) or a
|
|
180
|
+
pandas DataFrame (registered zero-copy via Arrow).
|
|
181
|
+
"""
|
|
182
|
+
if isinstance(source, pd.DataFrame):
|
|
183
|
+
self._reg += 1
|
|
184
|
+
name = f"_src_df_{self._reg}"
|
|
185
|
+
self.con.register(name, source)
|
|
186
|
+
return name
|
|
187
|
+
p = Path(source)
|
|
188
|
+
ext = p.suffix.lower()
|
|
189
|
+
path = _quote_str(str(p))
|
|
190
|
+
if ext == ".parquet":
|
|
191
|
+
return f"read_parquet({path})"
|
|
192
|
+
if ext == ".csv":
|
|
193
|
+
return f"read_csv({path}, header=true, auto_detect=true)"
|
|
194
|
+
if ext == ".tsv":
|
|
195
|
+
return f"read_csv({path}, header=true, auto_detect=true, delim='\t')"
|
|
196
|
+
if ext == ".json":
|
|
197
|
+
expr = f"read_json_auto({path})"
|
|
198
|
+
# DuckDB parses a columns-oriented JSON document — the DEFAULT
|
|
199
|
+
# output of pandas DataFrame.to_json() — as ONE row of index-keyed
|
|
200
|
+
# STRUCTs, which would silently corrupt the sample. Detect that
|
|
201
|
+
# shape (1 row, all STRUCT/MAP columns) and refuse with guidance.
|
|
202
|
+
info = self.con.execute(f"DESCRIBE SELECT * FROM {expr}").fetchall()
|
|
203
|
+
if info and all(
|
|
204
|
+
str(r[1]).upper().startswith(("STRUCT", "MAP")) for r in info
|
|
205
|
+
):
|
|
206
|
+
n = self.con.execute(
|
|
207
|
+
f"SELECT count(*) FROM (SELECT * FROM {expr} LIMIT 2) t"
|
|
208
|
+
).fetchone()[0]
|
|
209
|
+
if int(n) == 1:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f"{p.name} looks columns-oriented (the pandas to_json "
|
|
212
|
+
"default); the DuckDB engine reads records-oriented or "
|
|
213
|
+
"newline-delimited JSON — re-save with "
|
|
214
|
+
"orient='records' (or lines=True), or use --engine pandas"
|
|
215
|
+
)
|
|
216
|
+
return expr
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"DuckDB engine cannot read '{ext}' natively; supported: "
|
|
219
|
+
f"{', '.join(_NATIVE_READERS)} (or pass a pandas DataFrame)"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# ── introspection ─────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
def _count(self, source, src_sql: str) -> int:
|
|
225
|
+
"""Row count of ``source``, cached per file (free for DataFrames)."""
|
|
226
|
+
if isinstance(source, pd.DataFrame):
|
|
227
|
+
return len(source)
|
|
228
|
+
if src_sql not in self._count_cache:
|
|
229
|
+
self._count_cache[src_sql] = int(
|
|
230
|
+
self.con.execute(f"SELECT count(*) FROM {src_sql}").fetchone()[0]
|
|
231
|
+
)
|
|
232
|
+
return self._count_cache[src_sql]
|
|
233
|
+
|
|
234
|
+
def row_count(self, source) -> int:
|
|
235
|
+
return self._count(source, self._source_sql(source))
|
|
236
|
+
|
|
237
|
+
def columns(self, source) -> list[str]:
|
|
238
|
+
src = self._source_sql(source)
|
|
239
|
+
rel = self.con.execute(f"SELECT * FROM {src} LIMIT 0")
|
|
240
|
+
return [d[0] for d in rel.description]
|
|
241
|
+
|
|
242
|
+
def _set_seed(self, seed: int | None) -> None:
|
|
243
|
+
if seed is not None:
|
|
244
|
+
# setseed wants a value in [-1, 1]; map the int deterministically
|
|
245
|
+
self.con.execute(f"SELECT setseed({(int(seed) % 1000) / 1000.0})")
|
|
246
|
+
|
|
247
|
+
# ── stratification-column selection ───────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
def find_stratification_columns(
|
|
250
|
+
self, source, sample_count: int, exclude: Iterable[str] = ()
|
|
251
|
+
) -> list[str]:
|
|
252
|
+
"""Pick low-cardinality columns to stratify on, using approximate
|
|
253
|
+
distinct counts (HyperLogLog) so it stays cheap on huge inputs."""
|
|
254
|
+
exclude = set(exclude)
|
|
255
|
+
src = self._source_sql(source)
|
|
256
|
+
n_rows = self._count(source, src)
|
|
257
|
+
info = self.con.execute(f"DESCRIBE SELECT * FROM {src}").fetchall()
|
|
258
|
+
# classify via _duckdb_kind so type rules match stats() (and never
|
|
259
|
+
# match INTERVAL against the "int" numeric prefix)
|
|
260
|
+
cols = [
|
|
261
|
+
(r[0], _duckdb_kind(str(r[1]))) for r in info if r[0] not in exclude
|
|
262
|
+
]
|
|
263
|
+
if not cols:
|
|
264
|
+
return []
|
|
265
|
+
|
|
266
|
+
# one pass: approx distinct per column (HLL) + avg text length for
|
|
267
|
+
# string-ish columns (VARCHAR and ENUM both take this rule)
|
|
268
|
+
parts = []
|
|
269
|
+
for name, kind in cols:
|
|
270
|
+
q = _quote_ident(name)
|
|
271
|
+
parts.append(f"approx_count_distinct({q}) AS {_quote_ident(name + '::nd')}")
|
|
272
|
+
if kind == "categorical":
|
|
273
|
+
parts.append(
|
|
274
|
+
f"avg(length(CAST({q} AS VARCHAR))) AS {_quote_ident(name + '::len')}"
|
|
275
|
+
)
|
|
276
|
+
row = self.con.execute(f"SELECT {', '.join(parts)} FROM {src}").fetchdf().iloc[0]
|
|
277
|
+
|
|
278
|
+
candidates: list[tuple[str, int]] = []
|
|
279
|
+
for name, kind in cols:
|
|
280
|
+
n_unique = int(row[name + "::nd"] or 0)
|
|
281
|
+
if n_unique < 2 or n_unique > min(100, n_rows * 0.5):
|
|
282
|
+
continue
|
|
283
|
+
if kind == "numeric" and n_unique > min(20, n_rows * 0.3):
|
|
284
|
+
continue
|
|
285
|
+
if (name + "::len") in row and (row[name + "::len"] or 0) > 50:
|
|
286
|
+
continue
|
|
287
|
+
candidates.append((name, n_unique))
|
|
288
|
+
|
|
289
|
+
candidates.sort(key=lambda x: x[1]) # fewest categories first
|
|
290
|
+
selected: list[str] = []
|
|
291
|
+
combo = 1
|
|
292
|
+
for name, n_unique in candidates:
|
|
293
|
+
if combo * n_unique > sample_count:
|
|
294
|
+
break
|
|
295
|
+
combo *= n_unique
|
|
296
|
+
selected.append(name)
|
|
297
|
+
log.debug("engine stratification columns: %s", selected)
|
|
298
|
+
return selected
|
|
299
|
+
|
|
300
|
+
# ── approximate stats ─────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
def stats(
|
|
303
|
+
self,
|
|
304
|
+
source,
|
|
305
|
+
columns: Iterable[str] | None = None,
|
|
306
|
+
approximate: bool = True,
|
|
307
|
+
histogram_bins: int = 10,
|
|
308
|
+
top: int = 8,
|
|
309
|
+
distributions: bool = True,
|
|
310
|
+
) -> list[ColumnStats]:
|
|
311
|
+
"""Per-column :class:`ColumnStats`, computed in DuckDB.
|
|
312
|
+
|
|
313
|
+
With ``approximate`` (default) distinct counts use HyperLogLog
|
|
314
|
+
(``approx_count_distinct``) and the median uses ``approx_quantile`` — both
|
|
315
|
+
streaming, so column stats stay cheap over billions of rows. Set
|
|
316
|
+
``approximate=False`` for exact counts/quantiles on small inputs.
|
|
317
|
+
|
|
318
|
+
``distributions`` controls whether per-column histograms / top-values are
|
|
319
|
+
computed (one extra streaming pass per column). Turn it off for a single
|
|
320
|
+
cheap scalar pass across very wide (thousands-of-columns) inputs.
|
|
321
|
+
"""
|
|
322
|
+
con = self.con
|
|
323
|
+
src = self._source_sql(source)
|
|
324
|
+
total = self._count(source, src)
|
|
325
|
+
info = con.execute(f"DESCRIBE SELECT * FROM {src}").fetchall()
|
|
326
|
+
col_types = {r[0]: str(r[1]) for r in info}
|
|
327
|
+
if columns is None:
|
|
328
|
+
cols = list(col_types)
|
|
329
|
+
else:
|
|
330
|
+
cols = [str(c) for c in columns]
|
|
331
|
+
unknown = [c for c in cols if c not in col_types]
|
|
332
|
+
if unknown:
|
|
333
|
+
raise KeyError(f"Column(s) not found in source: {', '.join(unknown)}")
|
|
334
|
+
if not cols:
|
|
335
|
+
return []
|
|
336
|
+
|
|
337
|
+
# Pass A: all scalar aggregates for every column in ONE query
|
|
338
|
+
parts: list[str] = []
|
|
339
|
+
for i, c in enumerate(cols):
|
|
340
|
+
q = _quote_ident(c)
|
|
341
|
+
a = f"c{i}"
|
|
342
|
+
kind = _duckdb_kind(col_types[c])
|
|
343
|
+
parts.append(f"count({q}) AS {a}_cnt")
|
|
344
|
+
parts.append(
|
|
345
|
+
(f"approx_count_distinct({q})" if approximate else f"count(DISTINCT {q})")
|
|
346
|
+
+ f" AS {a}_nd"
|
|
347
|
+
)
|
|
348
|
+
if kind == "numeric":
|
|
349
|
+
# NaN is a VALUE to DuckDB, not NULL: unfiltered, it poisons
|
|
350
|
+
# min/max and makes stddev_samp raise "out of range" — filter
|
|
351
|
+
# every NaN-sensitive aggregate down to finite values
|
|
352
|
+
fin = f"FILTER (WHERE NOT isnan({q}::DOUBLE))"
|
|
353
|
+
parts += [
|
|
354
|
+
f"min({q}::DOUBLE) {fin} AS {a}_min",
|
|
355
|
+
f"max({q}::DOUBLE) {fin} AS {a}_max",
|
|
356
|
+
f"avg({q}::DOUBLE) {fin} AS {a}_avg",
|
|
357
|
+
f"stddev_samp({q}::DOUBLE) {fin} AS {a}_std",
|
|
358
|
+
(f"approx_quantile({q}::DOUBLE, 0.5)" if approximate
|
|
359
|
+
else f"quantile_cont({q}::DOUBLE, 0.5)") + f" {fin} AS {a}_med",
|
|
360
|
+
]
|
|
361
|
+
elif kind == "categorical":
|
|
362
|
+
parts.append(f"avg(length(CAST({q} AS VARCHAR))) AS {a}_len")
|
|
363
|
+
row = con.execute(f"SELECT {', '.join(parts)} FROM {src}").fetchdf().iloc[0]
|
|
364
|
+
|
|
365
|
+
out: list[ColumnStats] = []
|
|
366
|
+
for i, c in enumerate(cols):
|
|
367
|
+
a = f"c{i}"
|
|
368
|
+
kind = _duckdb_kind(col_types[c])
|
|
369
|
+
count = int(row[f"{a}_cnt"] or 0)
|
|
370
|
+
missing = total - count
|
|
371
|
+
# clamp: HLL can overestimate distinct counts past the row count,
|
|
372
|
+
# which would push unique_pct over 100% and skew suggest_type
|
|
373
|
+
unique = min(int(row[f"{a}_nd"] or 0), count)
|
|
374
|
+
if kind == "categorical" and (row.get(f"{a}_len") or 0) > 50:
|
|
375
|
+
kind = "text"
|
|
376
|
+
|
|
377
|
+
cs = ColumnStats(
|
|
378
|
+
name=str(c), dtype=col_types[c], kind=kind, count=count,
|
|
379
|
+
missing=missing, missing_pct=missing / total * 100 if total else 0.0,
|
|
380
|
+
unique=unique, unique_pct=unique / count * 100 if count else 0.0,
|
|
381
|
+
approximate=approximate,
|
|
382
|
+
)
|
|
383
|
+
# stratifiable flag, mirroring stats.is_stratifiable's rules
|
|
384
|
+
strat = 2 <= unique <= min(100, total * 0.5)
|
|
385
|
+
if kind == "numeric" and unique > min(20, total * 0.3):
|
|
386
|
+
strat = False
|
|
387
|
+
if kind == "text":
|
|
388
|
+
strat = False
|
|
389
|
+
cs.stratifiable = strat
|
|
390
|
+
|
|
391
|
+
if kind == "numeric" and count > 0:
|
|
392
|
+
cs.min = _to_float(row.get(f"{a}_min"))
|
|
393
|
+
cs.max = _to_float(row.get(f"{a}_max"))
|
|
394
|
+
cs.mean = _to_float(row.get(f"{a}_avg"))
|
|
395
|
+
cs.std = _to_float(row.get(f"{a}_std")) or 0.0
|
|
396
|
+
cs.median = _to_float(row.get(f"{a}_med"))
|
|
397
|
+
|
|
398
|
+
if distributions and count > 0:
|
|
399
|
+
self._fill_distribution(cs, src, c, kind, histogram_bins, top)
|
|
400
|
+
out.append(cs)
|
|
401
|
+
return out
|
|
402
|
+
|
|
403
|
+
def _fill_distribution(self, cs, src, col, kind, bins, top) -> None:
|
|
404
|
+
con = self.con
|
|
405
|
+
q = _quote_ident(col)
|
|
406
|
+
if kind == "numeric" and cs.min is not None and cs.max is not None and cs.min < cs.max:
|
|
407
|
+
lo, hi = float(cs.min), float(cs.max)
|
|
408
|
+
width = hi - lo
|
|
409
|
+
# equal-width bins via floor (width_bucket isn't available in
|
|
410
|
+
# DuckDB); clamp to [0, bins-1] so the max value lands in the last
|
|
411
|
+
# bin, and drop NaN explicitly (NaN is a value, not NULL, in DuckDB)
|
|
412
|
+
rows = con.execute(
|
|
413
|
+
f"SELECT least({bins - 1}, greatest(0, CAST(floor("
|
|
414
|
+
f"({q}::DOUBLE - {lo!r}) / {width!r} * {bins}) AS INTEGER))) AS b, "
|
|
415
|
+
f"count(*) AS n FROM {src} "
|
|
416
|
+
f"WHERE {q} IS NOT NULL AND NOT isnan({q}::DOUBLE) GROUP BY b"
|
|
417
|
+
).fetchall()
|
|
418
|
+
counts = [0] * bins
|
|
419
|
+
for b, n in rows:
|
|
420
|
+
counts[int(b)] += int(n)
|
|
421
|
+
cs.histogram = counts
|
|
422
|
+
edges = [lo + (hi - lo) * k / bins for k in range(bins + 1)]
|
|
423
|
+
cs.histogram_labels = [
|
|
424
|
+
f"{_fmt_num(edges[k])} – {_fmt_num(edges[k + 1])}" for k in range(bins)
|
|
425
|
+
]
|
|
426
|
+
elif kind in ("categorical", "boolean", "text", "datetime", "other"):
|
|
427
|
+
rows = con.execute(
|
|
428
|
+
f"SELECT CAST({q} AS VARCHAR) AS v, count(*) AS n FROM {src} "
|
|
429
|
+
f"WHERE {q} IS NOT NULL GROUP BY v ORDER BY n DESC, v LIMIT {top}"
|
|
430
|
+
).fetchall()
|
|
431
|
+
cs.top_values = [(str(v), int(n)) for v, n in rows]
|
|
432
|
+
cs.histogram = [int(n) for _, n in rows]
|
|
433
|
+
cs.histogram_labels = [str(v) for v, _ in rows]
|
|
434
|
+
|
|
435
|
+
# ── sampling ──────────────────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
def sample(
|
|
438
|
+
self,
|
|
439
|
+
source,
|
|
440
|
+
count: int,
|
|
441
|
+
use_random: bool = False,
|
|
442
|
+
exclude_columns: Iterable[str] = (),
|
|
443
|
+
strat_cols: list[str] | None = None,
|
|
444
|
+
seed: int | None = None,
|
|
445
|
+
) -> SampleResult:
|
|
446
|
+
"""Draw ``count`` rows out-of-core, returning a :class:`SampleResult`.
|
|
447
|
+
|
|
448
|
+
Stratifies automatically (unless ``use_random``) on suitable columns;
|
|
449
|
+
pass ``strat_cols`` to force a set. Only the sample is materialized.
|
|
450
|
+
"""
|
|
451
|
+
src = self._source_sql(source)
|
|
452
|
+
total = self._count(source, src)
|
|
453
|
+
log.info("engine sampling %d of %d rows (random=%s)", count, total, use_random)
|
|
454
|
+
|
|
455
|
+
if count >= total:
|
|
456
|
+
notes = [f"Requested {count} rows but source has {total}. Returning all rows."]
|
|
457
|
+
if total >= LARGE_ROW_THRESHOLD:
|
|
458
|
+
# the whole point of this engine is to never materialize the
|
|
459
|
+
# source — warn loudly before honoring an all-rows request
|
|
460
|
+
warn = (
|
|
461
|
+
f"Materializing all {total:,} rows into memory defeats "
|
|
462
|
+
"out-of-core sampling and may exhaust RAM — request fewer rows."
|
|
463
|
+
)
|
|
464
|
+
log.warning(warn)
|
|
465
|
+
notes.append(f"WARNING: {warn}")
|
|
466
|
+
data = self.con.execute(f"SELECT * FROM {src}").df()
|
|
467
|
+
return SampleResult(data=data, method="all", requested=count, notes=notes)
|
|
468
|
+
|
|
469
|
+
self._set_seed(seed)
|
|
470
|
+
|
|
471
|
+
if use_random:
|
|
472
|
+
return self._reservoir(src, count, seed)
|
|
473
|
+
|
|
474
|
+
if strat_cols is None:
|
|
475
|
+
strat_cols = self.find_stratification_columns(source, count, exclude_columns)
|
|
476
|
+
if not strat_cols:
|
|
477
|
+
result = self._reservoir(src, count, seed)
|
|
478
|
+
result.notes = ["No suitable stratification columns found; using reservoir sampling."]
|
|
479
|
+
return result
|
|
480
|
+
|
|
481
|
+
return self._stratified(src, count, strat_cols, seed)
|
|
482
|
+
|
|
483
|
+
def _reservoir(self, src: str, count: int, seed: int | None) -> SampleResult:
|
|
484
|
+
rep = f" REPEATABLE ({int(seed)})" if seed is not None else ""
|
|
485
|
+
data = self.con.execute(
|
|
486
|
+
f"SELECT * FROM {src} USING SAMPLE reservoir({int(count)} ROWS){rep}"
|
|
487
|
+
).df()
|
|
488
|
+
return SampleResult(
|
|
489
|
+
data=data, method="random", requested=count,
|
|
490
|
+
notes=["Mode: DuckDB reservoir sampling (out-of-core)"],
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
def _stratified(
|
|
494
|
+
self, src: str, count: int, strat_cols: list[str], seed: int | None
|
|
495
|
+
) -> SampleResult:
|
|
496
|
+
cols_sql = ", ".join(_quote_ident(c) for c in strat_cols)
|
|
497
|
+
# pass 1: stratum sizes (small — one row per stratum). ORDER BY pins
|
|
498
|
+
# the stratum order: DuckDB's GROUP BY row order is nondeterministic,
|
|
499
|
+
# and _proportional_allocation breaks remainder ties by position, so
|
|
500
|
+
# an unpinned order would make even seeded runs nondeterministic.
|
|
501
|
+
group_df = self.con.execute(
|
|
502
|
+
f"SELECT {cols_sql}, count(*) AS _n FROM {src} "
|
|
503
|
+
f"GROUP BY {cols_sql} ORDER BY {cols_sql} NULLS LAST"
|
|
504
|
+
).df()
|
|
505
|
+
allocations = _proportional_allocation(group_df["_n"].to_numpy(), count)
|
|
506
|
+
group_df["_alloc"] = allocations
|
|
507
|
+
|
|
508
|
+
# pass 2: proportional per-stratum sampling. row_number over a random
|
|
509
|
+
# order per partition, keep the first _alloc of each stratum. DuckDB
|
|
510
|
+
# parallelizes the partitions and spills if needed.
|
|
511
|
+
alloc_tbl = f"_alloc_{self._reg}"
|
|
512
|
+
self._reg += 1
|
|
513
|
+
self.con.register(alloc_tbl, group_df)
|
|
514
|
+
join_on = " AND ".join(
|
|
515
|
+
f"r.{_quote_ident(c)} IS NOT DISTINCT FROM a.{_quote_ident(c)}"
|
|
516
|
+
for c in strat_cols
|
|
517
|
+
)
|
|
518
|
+
query = f"""
|
|
519
|
+
WITH ranked AS (
|
|
520
|
+
SELECT *, row_number() OVER (
|
|
521
|
+
PARTITION BY {cols_sql} ORDER BY random()
|
|
522
|
+
) AS _rn
|
|
523
|
+
FROM {src}
|
|
524
|
+
)
|
|
525
|
+
SELECT r.* EXCLUDE (_rn)
|
|
526
|
+
FROM ranked r
|
|
527
|
+
JOIN {alloc_tbl} a ON {join_on}
|
|
528
|
+
WHERE r._rn <= a._alloc
|
|
529
|
+
"""
|
|
530
|
+
# DuckDB's random() ordering is only reproducible single-threaded, so a
|
|
531
|
+
# seeded run goes single-threaded for determinism; unseeded runs keep all
|
|
532
|
+
# cores (the distribution is preserved either way).
|
|
533
|
+
reproducible = seed is not None
|
|
534
|
+
if reproducible:
|
|
535
|
+
self.con.execute("PRAGMA threads=1")
|
|
536
|
+
self._set_seed(seed)
|
|
537
|
+
try:
|
|
538
|
+
data = self.con.execute(query).df()
|
|
539
|
+
finally:
|
|
540
|
+
if reproducible:
|
|
541
|
+
self.con.execute(f"PRAGMA threads={self.threads}")
|
|
542
|
+
|
|
543
|
+
group_sizes = group_df.set_index(strat_cols)["_n"]
|
|
544
|
+
alloc_series = group_df.set_index(strat_cols)["_alloc"]
|
|
545
|
+
return SampleResult(
|
|
546
|
+
data=data, method="stratified", requested=count,
|
|
547
|
+
strat_cols=list(strat_cols), group_sizes=group_sizes, allocations=alloc_series,
|
|
548
|
+
notes=[f"Stratifying on columns (DuckDB, out-of-core): {list(strat_cols)}"],
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# ── module-level conveniences ──────────────────────────────────────────────────
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def duckdb_available() -> bool:
|
|
556
|
+
"""Whether the optional DuckDB dependency is importable."""
|
|
557
|
+
try:
|
|
558
|
+
import duckdb # noqa: F401, PLC0415
|
|
559
|
+
return True
|
|
560
|
+
except ImportError:
|
|
561
|
+
return False
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def should_use_engine(source, row_threshold: int = LARGE_ROW_THRESHOLD) -> bool:
|
|
565
|
+
"""Heuristic for auto-selecting the DuckDB engine: Parquet inputs (always a
|
|
566
|
+
win via pushdown) or files large enough that pandas would strain."""
|
|
567
|
+
if not duckdb_available():
|
|
568
|
+
return False
|
|
569
|
+
if isinstance(source, pd.DataFrame):
|
|
570
|
+
return len(source) >= row_threshold
|
|
571
|
+
ext = Path(source).suffix.lower()
|
|
572
|
+
if ext == ".parquet":
|
|
573
|
+
return True
|
|
574
|
+
if ext not in _NATIVE_READERS:
|
|
575
|
+
return False # e.g. Excel: DuckDB cannot read it natively
|
|
576
|
+
try:
|
|
577
|
+
# rough gate on file size (bytes) as a proxy for row count
|
|
578
|
+
return os.path.getsize(source) >= row_threshold * 20
|
|
579
|
+
except OSError:
|
|
580
|
+
return False
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def large_materialization_warning(
|
|
584
|
+
n_rows: int, n_cols: int, row_threshold: int = LARGE_ROW_THRESHOLD
|
|
585
|
+
) -> str | None:
|
|
586
|
+
"""A warning to show before loading a big source fully into pandas.
|
|
587
|
+
|
|
588
|
+
Parquet compresses hard on disk, so a modest file can explode in memory —
|
|
589
|
+
surface the speedbump and point at the out-of-core engine.
|
|
590
|
+
"""
|
|
591
|
+
if n_rows >= row_threshold:
|
|
592
|
+
return (
|
|
593
|
+
f"{n_rows:,} rows x {n_cols} columns is large: loading it fully into "
|
|
594
|
+
"pandas may exhaust memory (Parquet especially expands well beyond its "
|
|
595
|
+
"on-disk size). The DuckDB engine samples it out-of-core without "
|
|
596
|
+
"materializing the whole dataset."
|
|
597
|
+
)
|
|
598
|
+
return None
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def sample(
|
|
602
|
+
source,
|
|
603
|
+
count: int,
|
|
604
|
+
use_random: bool = False,
|
|
605
|
+
exclude_columns: Iterable[str] = (),
|
|
606
|
+
strat_cols: list[str] | None = None,
|
|
607
|
+
seed: int | None = None,
|
|
608
|
+
threads: int | None = None,
|
|
609
|
+
memory_limit: str | None = None,
|
|
610
|
+
) -> SampleResult:
|
|
611
|
+
"""Convenience: open a :class:`DuckDBEngine`, sample ``source``, close it."""
|
|
612
|
+
with DuckDBEngine(threads=threads, memory_limit=memory_limit) as engine:
|
|
613
|
+
return engine.sample(
|
|
614
|
+
source, count, use_random=use_random,
|
|
615
|
+
exclude_columns=exclude_columns, strat_cols=strat_cols, seed=seed,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def stats(
|
|
620
|
+
source,
|
|
621
|
+
columns: Iterable[str] | None = None,
|
|
622
|
+
approximate: bool = True,
|
|
623
|
+
threads: int | None = None,
|
|
624
|
+
memory_limit: str | None = None,
|
|
625
|
+
) -> list[ColumnStats]:
|
|
626
|
+
"""Convenience: open a :class:`DuckDBEngine`, compute stats, close it."""
|
|
627
|
+
with DuckDBEngine(threads=threads, memory_limit=memory_limit) as engine:
|
|
628
|
+
return engine.stats(source, columns=columns, approximate=approximate)
|