data-sampler 3.5.0__tar.gz → 3.5.2__tar.gz
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-3.5.0 → data_sampler-3.5.2}/CHANGELOG.md +41 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/PKG-INFO +8 -3
- {data_sampler-3.5.0 → data_sampler-3.5.2}/README.md +7 -2
- {data_sampler-3.5.0 → data_sampler-3.5.2}/pyproject.toml +1 -1
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/__init__.py +1 -1
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/engine.py +39 -9
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/sampling.py +4 -3
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/stats.py +54 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/tui/app.py +10 -1
- {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_engine.py +59 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_sampling.py +10 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_stats.py +78 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_tui.py +25 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/.gitattributes +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/.github/workflows/release.yml +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/.gitignore +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/LICENSE +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/ROADMAP.md +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/docs/RELEASING.md +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/docs/TROUBLESHOOTING.md +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/docs/img/tui-columns.png +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/docs/img/tui-file.png +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/docs/img/tui-report.png +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/examples/employees.csv +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/examples/using_data_sampler.ipynb +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/requirements.txt +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/scripts/run-tui.bat +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/scripts/run-tui.sh +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/__main__.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/_logging.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/_names.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/anonymize.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/cli.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/io.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/reduce.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/report.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/tui/__init__.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/workflow.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/conftest.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_anonymize.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_cli.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_io.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_names.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_reduce.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_report.py +0 -0
- {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_workflow.py +0 -0
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.5.2 — 2026-07-24
|
|
4
|
+
|
|
5
|
+
- **Continuous numeric columns are skipped by auto-stratification.** A numeric
|
|
6
|
+
column with any fractional values (prices, rates, measurements) is no longer
|
|
7
|
+
a stratification candidate, even at low cardinality — sampling proportionally
|
|
8
|
+
over arbitrary points on a continuum doesn't preserve anything meaningful.
|
|
9
|
+
Whole-number columns (ratings, counts — including float columns that hold
|
|
10
|
+
only whole numbers, the usual dtype once NaNs appear) remain candidates. The
|
|
11
|
+
rule applies uniformly to the pandas path (`is_stratifiable`), the DuckDB
|
|
12
|
+
engine (`find_stratification_columns` and the `stratifiable` stats flag, via
|
|
13
|
+
a same-pass `bool_or(x <> trunc(x))` probe on float-typed columns), and
|
|
14
|
+
therefore to the TUI's strat column display. Explicit `strat_cols=` still
|
|
15
|
+
forces any column.
|
|
16
|
+
- **The rule is value-based, not dtype-based**, so both paths reach the same
|
|
17
|
+
verdict on every representation of the same numbers: parquet DECIMAL columns
|
|
18
|
+
(object-of-`Decimal` after `read_parquet`, or Arrow-backed decimal under
|
|
19
|
+
`dtype_backend='pyarrow'`), float-backed categorical columns (judged on
|
|
20
|
+
observed values — unused categories left behind by row filtering don't
|
|
21
|
+
count, matching what DuckDB registration sees), and object columns of
|
|
22
|
+
floats all count as continuous when their values are fractional. Caught by
|
|
23
|
+
pre-release adversarial review — the initial dtype-gated pandas check let a
|
|
24
|
+
DECIMAL parquet file stratify differently depending on whether the DuckDB
|
|
25
|
+
engine handled it.
|
|
26
|
+
- **Fixed: `engine.stats()` crashed with `STDDEV_SAMP is out of range` on any
|
|
27
|
+
numeric column containing ±inf** (pre-existing since the engine shipped in
|
|
28
|
+
v3.2). The shared aggregate filter now keeps only finite values — as its own
|
|
29
|
+
comment always promised — instead of filtering just NaN.
|
|
30
|
+
- The TUI's "not a stratification candidate" hint now lists continuous values
|
|
31
|
+
among the possible reasons.
|
|
32
|
+
|
|
33
|
+
## v3.5.1 — 2026-07-24
|
|
34
|
+
|
|
35
|
+
- **The "skip from reduction (PCA)" toggle is hidden for non-numeric columns.**
|
|
36
|
+
PCA reduction only ever operates on continuous numeric columns (booleans and
|
|
37
|
+
datetimes are explicitly rejected; strings/categoricals never qualify), so the
|
|
38
|
+
toggle is meaningless on those columns. It now appears in the detail panel only
|
|
39
|
+
when the selected column is numeric — matching the columns table, which already
|
|
40
|
+
renders `—` in the reduce cell for non-numeric columns. No behavior change to
|
|
41
|
+
the reduction itself: skipped columns were, and remain, held out of the PCA
|
|
42
|
+
math entirely and passed through to the output unchanged.
|
|
43
|
+
|
|
3
44
|
## v3.5.0 — 2026-07-24
|
|
4
45
|
|
|
5
46
|
- **Names anonymizer is now gender- and ethnicity-aware.** The bundled library
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: data-sampler
|
|
3
|
-
Version: 3.5.
|
|
3
|
+
Version: 3.5.2
|
|
4
4
|
Summary: Hand someone data that looks and behaves like your production data, isn't your production data, and provably kept its statistical variety — stratified sampling + consistent anonymization, with a terminal UI.
|
|
5
5
|
Project-URL: Homepage, https://github.com/aaronified/data-sampler
|
|
6
6
|
Project-URL: Changelog, https://github.com/aaronified/data-sampler/blob/main/CHANGELOG.md
|
|
@@ -420,8 +420,13 @@ are left alone so the categories you stratify on survive).
|
|
|
420
420
|
|
|
421
421
|
**Stratified (default):** columns suitable for stratification are detected
|
|
422
422
|
automatically — categorical or low-cardinality columns with 2–100 unique
|
|
423
|
-
values; long text
|
|
424
|
-
|
|
423
|
+
values; long text, ID-like numeric, and continuous numeric columns (any
|
|
424
|
+
fractional values — prices, rates, measurements) are avoided, as are any
|
|
425
|
+
columns you mark as skipped. The continuous check looks at values, not
|
|
426
|
+
storage types: parquet DECIMAL and float-backed categorical columns count
|
|
427
|
+
too. Whole-number numeric columns (ratings, counts — including floats that
|
|
428
|
+
hold only whole numbers) remain candidates. Rows are grouped by the joint
|
|
429
|
+
combination of
|
|
425
430
|
all selected columns and sampled proportionally per group, so the sample
|
|
426
431
|
mirrors the original joint distribution. Missing values count as their own
|
|
427
432
|
category. A side-by-side distribution report is produced for every run.
|
|
@@ -365,8 +365,13 @@ are left alone so the categories you stratify on survive).
|
|
|
365
365
|
|
|
366
366
|
**Stratified (default):** columns suitable for stratification are detected
|
|
367
367
|
automatically — categorical or low-cardinality columns with 2–100 unique
|
|
368
|
-
values; long text
|
|
369
|
-
|
|
368
|
+
values; long text, ID-like numeric, and continuous numeric columns (any
|
|
369
|
+
fractional values — prices, rates, measurements) are avoided, as are any
|
|
370
|
+
columns you mark as skipped. The continuous check looks at values, not
|
|
371
|
+
storage types: parquet DECIMAL and float-backed categorical columns count
|
|
372
|
+
too. Whole-number numeric columns (ratings, counts — including floats that
|
|
373
|
+
hold only whole numbers) remain candidates. Rows are grouped by the joint
|
|
374
|
+
combination of
|
|
370
375
|
all selected columns and sampled proportionally per group, so the sample
|
|
371
376
|
mirrors the original joint distribution. Missing values count as their own
|
|
372
377
|
category. A side-by-side distribution report is produced for every run.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "data-sampler"
|
|
7
|
-
version = "3.5.
|
|
7
|
+
version = "3.5.2"
|
|
8
8
|
description = "Hand someone data that looks and behaves like your production data, isn't your production data, and provably kept its statistical variety — stratified sampling + consistent anonymization, with a terminal UI."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -122,6 +122,15 @@ def _duckdb_kind(dtype: str) -> str:
|
|
|
122
122
|
return "other"
|
|
123
123
|
|
|
124
124
|
|
|
125
|
+
def _duckdb_can_be_fractional(dtype: str) -> bool:
|
|
126
|
+
"""Whether a scalar DuckDB numeric type can hold fractional values.
|
|
127
|
+
|
|
128
|
+
Integer types cannot, so the continuous-column probe (a value scan) is
|
|
129
|
+
skipped for them.
|
|
130
|
+
"""
|
|
131
|
+
return dtype.lower().startswith(("decimal", "double", "float", "real", "numeric"))
|
|
132
|
+
|
|
133
|
+
|
|
125
134
|
def _proportional_allocation(sizes: np.ndarray, count: int) -> np.ndarray:
|
|
126
135
|
"""Largest-remainder proportional allocation summing exactly to ``count``.
|
|
127
136
|
|
|
@@ -295,30 +304,43 @@ class DuckDBEngine:
|
|
|
295
304
|
# classify via _duckdb_kind so type rules match stats() (and never
|
|
296
305
|
# match INTERVAL against the "int" numeric prefix)
|
|
297
306
|
cols = [
|
|
298
|
-
(r[0], _duckdb_kind(str(r[1]))
|
|
307
|
+
(r[0], _duckdb_kind(str(r[1])), str(r[1]))
|
|
308
|
+
for r in info
|
|
309
|
+
if r[0] not in exclude
|
|
299
310
|
]
|
|
300
311
|
if not cols:
|
|
301
312
|
return []
|
|
302
313
|
|
|
303
314
|
# one pass: approx distinct per column (HLL) + avg text length for
|
|
304
|
-
# string-ish columns (VARCHAR and ENUM both take this rule)
|
|
315
|
+
# string-ish columns (VARCHAR and ENUM both take this rule) + a
|
|
316
|
+
# fractional-values probe for float-typed columns (continuous columns
|
|
317
|
+
# are skipped as strata, mirroring stats.is_stratifiable)
|
|
305
318
|
parts = []
|
|
306
|
-
for name, kind in cols:
|
|
319
|
+
for name, kind, dtype in cols:
|
|
307
320
|
q = _quote_ident(name)
|
|
308
321
|
parts.append(f"approx_count_distinct({q}) AS {_quote_ident(name + '::nd')}")
|
|
309
322
|
if kind == "categorical":
|
|
310
323
|
parts.append(
|
|
311
324
|
f"avg(length(CAST({q} AS VARCHAR))) AS {_quote_ident(name + '::len')}"
|
|
312
325
|
)
|
|
326
|
+
elif kind == "numeric" and _duckdb_can_be_fractional(dtype):
|
|
327
|
+
parts.append(
|
|
328
|
+
f"bool_or({q}::DOUBLE <> trunc({q}::DOUBLE)) "
|
|
329
|
+
f"FILTER (WHERE isfinite({q}::DOUBLE)) "
|
|
330
|
+
f"AS {_quote_ident(name + '::frac')}"
|
|
331
|
+
)
|
|
313
332
|
row = self.con.execute(f"SELECT {', '.join(parts)} FROM {src}").fetchdf().iloc[0]
|
|
314
333
|
|
|
315
334
|
candidates: list[tuple[str, int]] = []
|
|
316
|
-
for name, kind in cols:
|
|
335
|
+
for name, kind, _dtype in cols:
|
|
317
336
|
n_unique = int(row[name + "::nd"] or 0)
|
|
318
337
|
if n_unique < 2 or n_unique > min(100, n_rows * 0.5):
|
|
319
338
|
continue
|
|
320
339
|
if kind == "numeric" and n_unique > min(20, n_rows * 0.3):
|
|
321
340
|
continue
|
|
341
|
+
frac = row.get(name + "::frac")
|
|
342
|
+
if frac is not None and pd.notna(frac) and bool(frac):
|
|
343
|
+
continue
|
|
322
344
|
if (name + "::len") in row and (row[name + "::len"] or 0) > 50:
|
|
323
345
|
continue
|
|
324
346
|
candidates.append((name, n_unique))
|
|
@@ -384,9 +406,9 @@ class DuckDBEngine:
|
|
|
384
406
|
)
|
|
385
407
|
if kind == "numeric":
|
|
386
408
|
# NaN is a VALUE to DuckDB, not NULL: unfiltered, it poisons
|
|
387
|
-
# min/max and makes stddev_samp raise "out of range" —
|
|
388
|
-
# every
|
|
389
|
-
fin = f"FILTER (WHERE
|
|
409
|
+
# min/max; and ±inf makes stddev_samp raise "out of range" —
|
|
410
|
+
# filter every sensitive aggregate down to finite values
|
|
411
|
+
fin = f"FILTER (WHERE isfinite({q}::DOUBLE))"
|
|
390
412
|
parts += [
|
|
391
413
|
f"min({q}::DOUBLE) {fin} AS {a}_min",
|
|
392
414
|
f"max({q}::DOUBLE) {fin} AS {a}_max",
|
|
@@ -395,6 +417,10 @@ class DuckDBEngine:
|
|
|
395
417
|
(f"approx_quantile({q}::DOUBLE, 0.5)" if approximate
|
|
396
418
|
else f"quantile_cont({q}::DOUBLE, 0.5)") + f" {fin} AS {a}_med",
|
|
397
419
|
]
|
|
420
|
+
if _duckdb_can_be_fractional(col_types[c]):
|
|
421
|
+
parts.append(
|
|
422
|
+
f"bool_or({q}::DOUBLE <> trunc({q}::DOUBLE)) {fin} AS {a}_frac"
|
|
423
|
+
)
|
|
398
424
|
elif kind == "categorical":
|
|
399
425
|
parts.append(f"avg(length(CAST({q} AS VARCHAR))) AS {a}_len")
|
|
400
426
|
row = con.execute(f"SELECT {', '.join(parts)} FROM {src}").fetchdf().iloc[0]
|
|
@@ -421,6 +447,9 @@ class DuckDBEngine:
|
|
|
421
447
|
strat = 2 <= unique <= min(100, total * 0.5)
|
|
422
448
|
if kind == "numeric" and unique > min(20, total * 0.3):
|
|
423
449
|
strat = False
|
|
450
|
+
frac = row.get(f"{a}_frac")
|
|
451
|
+
if pd.notna(frac) and bool(frac):
|
|
452
|
+
strat = False # continuous (fractional-valued) numeric column
|
|
424
453
|
if kind == "text":
|
|
425
454
|
strat = False
|
|
426
455
|
cs.stratifiable = strat
|
|
@@ -445,12 +474,13 @@ class DuckDBEngine:
|
|
|
445
474
|
width = hi - lo
|
|
446
475
|
# equal-width bins via floor (width_bucket isn't available in
|
|
447
476
|
# DuckDB); clamp to [0, bins-1] so the max value lands in the last
|
|
448
|
-
# bin, and drop NaN explicitly (NaN is a value, not NULL, in
|
|
477
|
+
# bin, and drop NaN/±inf explicitly (NaN is a value, not NULL, in
|
|
478
|
+
# DuckDB, and inf would overflow the INTEGER bin cast)
|
|
449
479
|
rows = con.execute(
|
|
450
480
|
f"SELECT least({bins - 1}, greatest(0, CAST(floor("
|
|
451
481
|
f"({q}::DOUBLE - {lo!r}) / {width!r} * {bins}) AS INTEGER))) AS b, "
|
|
452
482
|
f"count(*) AS n FROM {src} "
|
|
453
|
-
f"WHERE {q} IS NOT NULL AND
|
|
483
|
+
f"WHERE {q} IS NOT NULL AND isfinite({q}::DOUBLE) GROUP BY b"
|
|
454
484
|
).fetchall()
|
|
455
485
|
counts = [0] * bins
|
|
456
486
|
for b, n in rows:
|
|
@@ -40,9 +40,10 @@ def find_stratification_columns(
|
|
|
40
40
|
"""Pick columns suitable for stratification.
|
|
41
41
|
|
|
42
42
|
Candidates are categorical / low-cardinality columns with 2–100 unique
|
|
43
|
-
values; long text
|
|
44
|
-
|
|
45
|
-
final set is pruned so the
|
|
43
|
+
values; long text, ID-like numeric, and continuous numeric columns
|
|
44
|
+
(fractional values) are skipped. Columns listed in ``exclude`` are never
|
|
45
|
+
considered (the user's "skip" selections). The final set is pruned so the
|
|
46
|
+
intersection-group count fits ``sample_count``.
|
|
46
47
|
"""
|
|
47
48
|
exclude = set(exclude)
|
|
48
49
|
candidates = []
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import numbers
|
|
5
6
|
from dataclasses import dataclass, field
|
|
6
7
|
|
|
7
8
|
import numpy as np
|
|
@@ -81,6 +82,47 @@ def sparkline(counts: list[int]) -> str:
|
|
|
81
82
|
return "".join(out)
|
|
82
83
|
|
|
83
84
|
|
|
85
|
+
def _fractionable_values(series: pd.Series) -> np.ndarray | None:
|
|
86
|
+
"""Unique non-null OBSERVED values as float64 for columns whose
|
|
87
|
+
representation can hold fractional numbers — float dtypes (numpy,
|
|
88
|
+
nullable, or Arrow-backed), decimal extension dtypes (Arrow decimal128
|
|
89
|
+
from ``read_parquet(dtype_backend='pyarrow')``), float-backed
|
|
90
|
+
categoricals, and object columns of floats/Decimals (how parquet DECIMAL
|
|
91
|
+
and SQL drivers arrive by default). None for everything else (ints,
|
|
92
|
+
bools, strings, dates): those can never be continuous. Mirrors which
|
|
93
|
+
DuckDB types get the engine-side probe
|
|
94
|
+
(:func:`data_sampler.engine._duckdb_can_be_fractional`), and rules on
|
|
95
|
+
observed values — not declared categories — because that is all the
|
|
96
|
+
engine ever sees, so both paths reach the same verdict. Callers gate on
|
|
97
|
+
cardinality first — the returned array is only ever unique values, so it
|
|
98
|
+
stays small.
|
|
99
|
+
"""
|
|
100
|
+
non_null = series.dropna()
|
|
101
|
+
if isinstance(series.dtype, pd.CategoricalDtype):
|
|
102
|
+
if not pd.api.types.is_float_dtype(series.cat.categories.dtype):
|
|
103
|
+
return None
|
|
104
|
+
return np.asarray(non_null.unique(), dtype=float)
|
|
105
|
+
if (
|
|
106
|
+
pd.api.types.is_numeric_dtype(series)
|
|
107
|
+
and not pd.api.types.is_integer_dtype(series)
|
|
108
|
+
and not pd.api.types.is_bool_dtype(series)
|
|
109
|
+
and not pd.api.types.is_complex_dtype(series)
|
|
110
|
+
):
|
|
111
|
+
try:
|
|
112
|
+
return np.asarray(non_null.unique(), dtype=float)
|
|
113
|
+
except (TypeError, ValueError, OverflowError):
|
|
114
|
+
return None
|
|
115
|
+
if series.dtype == object:
|
|
116
|
+
uniq = non_null.unique()
|
|
117
|
+
if len(uniq) == 0 or not all(isinstance(v, numbers.Number) for v in uniq):
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
return np.array([float(v) for v in uniq], dtype=float)
|
|
121
|
+
except (TypeError, ValueError, OverflowError):
|
|
122
|
+
return None
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
84
126
|
def is_stratifiable(series: pd.Series, n_rows: int, n_unique: int | None = None) -> bool:
|
|
85
127
|
"""Whether a column passes the auto-stratification candidate checks.
|
|
86
128
|
|
|
@@ -100,6 +142,18 @@ def is_stratifiable(series: pd.Series, n_rows: int, n_unique: int | None = None)
|
|
|
100
142
|
if pd.api.types.is_numeric_dtype(series):
|
|
101
143
|
if n_unique > min(20, n_rows * 0.3):
|
|
102
144
|
return False
|
|
145
|
+
# continuous columns (fractional values — prices, rates, measurements)
|
|
146
|
+
# make poor strata even at low cardinality: skip them by default. The
|
|
147
|
+
# check is value-based, not dtype-based, so parquet DECIMAL (object of
|
|
148
|
+
# decimal.Decimal) and float-backed categorical columns rule the same way
|
|
149
|
+
# the DuckDB engine's probe does. Whole-number floats stay candidates —
|
|
150
|
+
# integer-coded columns (ratings, counts) usually arrive as float64 once
|
|
151
|
+
# NaNs appear.
|
|
152
|
+
vals = _fractionable_values(series)
|
|
153
|
+
if vals is not None:
|
|
154
|
+
finite = vals[np.isfinite(vals)]
|
|
155
|
+
if finite.size and not np.all(finite == np.round(finite)):
|
|
156
|
+
return False
|
|
103
157
|
return True
|
|
104
158
|
|
|
105
159
|
|
|
@@ -826,7 +826,10 @@ class ColumnsScreen(Screen):
|
|
|
826
826
|
t.append(f" {count:,} ({count / total * 100:.1f}%)\n", style=DIM)
|
|
827
827
|
if not s.stratifiable:
|
|
828
828
|
t.append("\nnot a stratification candidate ", style=DIM)
|
|
829
|
-
t.append(
|
|
829
|
+
t.append(
|
|
830
|
+
"(too many unique values, continuous values, long text, or constant)",
|
|
831
|
+
style=DIM,
|
|
832
|
+
)
|
|
830
833
|
self.query_one("#detail", Static).update(t)
|
|
831
834
|
|
|
832
835
|
# ── config panel sync ────────────────────────────────────────────────────
|
|
@@ -882,6 +885,12 @@ class ColumnsScreen(Screen):
|
|
|
882
885
|
skip_reduce = self.query_one("#skip-reduce", Switch)
|
|
883
886
|
if skip_reduce.value != cfg.skip_reduce:
|
|
884
887
|
skip_reduce.value = cfg.skip_reduce
|
|
888
|
+
# PCA reduction only ever touches continuous numeric columns, so the
|
|
889
|
+
# "skip from reduction" toggle is meaningless for string / categorical
|
|
890
|
+
# / boolean / datetime columns — hide the whole row for those
|
|
891
|
+
self.query_one("#reduce-skip-row").set_class(
|
|
892
|
+
self.stats[name].kind != "numeric", "hidden"
|
|
893
|
+
)
|
|
885
894
|
# names gender / ethnicity controls
|
|
886
895
|
gsel = self.query_one("#opt-names-gender", Select)
|
|
887
896
|
if gsel.value != cfg.gender:
|
|
@@ -135,6 +135,65 @@ def test_auto_stratification_picks_low_cardinality(engine, big_df):
|
|
|
135
135
|
assert "id" not in cols and "note" not in cols
|
|
136
136
|
|
|
137
137
|
|
|
138
|
+
def test_auto_stratification_skips_continuous_numeric(engine, big_df):
|
|
139
|
+
# low-cardinality but fractional-valued (continuous) → skipped by default;
|
|
140
|
+
# whole-number floats (integer codes stored as DOUBLE) stay candidates
|
|
141
|
+
df = big_df.copy()
|
|
142
|
+
df["price"] = np.tile([9.99, 19.99, 29.99, 39.99], len(df) // 4)
|
|
143
|
+
df["rating"] = np.tile([1.0, 2.0, 3.0, 4.0], len(df) // 4)
|
|
144
|
+
cols = engine.find_stratification_columns(df, 1000)
|
|
145
|
+
assert "price" not in cols
|
|
146
|
+
assert "rating" in cols
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_stats_stratifiable_flag_skips_continuous_numeric(engine, big_df):
|
|
150
|
+
df = big_df.copy()
|
|
151
|
+
df["price"] = np.tile([9.99, 19.99, 29.99, 39.99], len(df) // 4)
|
|
152
|
+
df["rating"] = np.tile([1.0, 2.0, 3.0, 4.0], len(df) // 4)
|
|
153
|
+
by_name = {s.name: s for s in engine.stats(df, columns=["price", "rating", "tier"])}
|
|
154
|
+
assert not by_name["price"].stratifiable
|
|
155
|
+
assert by_name["rating"].stratifiable
|
|
156
|
+
assert by_name["tier"].stratifiable
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_decimal_parquet_agrees_across_paths(tmp_path, engine):
|
|
160
|
+
# parquet DECIMAL loads as object-of-Decimal in pandas; both paths must
|
|
161
|
+
# exclude the fractional column and keep the categorical
|
|
162
|
+
p = str(tmp_path / "dec.parquet").replace("\\", "/")
|
|
163
|
+
engine.con.execute(
|
|
164
|
+
"COPY (SELECT CAST(9.99 + (i % 4) * 10 AS DECIMAL(6,2)) AS price, "
|
|
165
|
+
f"'g' || (i % 3) AS grp FROM range(1000) t(i)) TO '{p}' (FORMAT PARQUET)"
|
|
166
|
+
)
|
|
167
|
+
assert engine.find_stratification_columns(p, 200) == ["grp"]
|
|
168
|
+
from data_sampler.sampling import find_stratification_columns
|
|
169
|
+
|
|
170
|
+
assert find_stratification_columns(pd.read_parquet(p), 200) == ["grp"]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_categorical_float_agrees_across_paths(engine):
|
|
174
|
+
# DuckDB registration flattens category-of-float to DOUBLE — the pandas
|
|
175
|
+
# path must reach the same verdict on the same DataFrame
|
|
176
|
+
df = pd.DataFrame(
|
|
177
|
+
{
|
|
178
|
+
"band": pd.Series([1.5, 2.5, 3.5, 4.5] * 250).astype("category"),
|
|
179
|
+
"grp": ["a", "b"] * 500,
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
assert engine.find_stratification_columns(df, 200) == ["grp"]
|
|
183
|
+
from data_sampler.sampling import find_stratification_columns
|
|
184
|
+
|
|
185
|
+
assert find_stratification_columns(df, 200) == ["grp"]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_stats_handles_infinite_values(engine):
|
|
189
|
+
# ±inf passed the old NOT-isnan filter and blew up stddev_samp, crashing
|
|
190
|
+
# the whole Pass-A query; aggregates are finite-only now
|
|
191
|
+
df = pd.DataFrame({"x": [1.0, 2.0, 3.0, np.inf] * 250, "cat": ["a", "b"] * 500})
|
|
192
|
+
by_name = {s.name: s for s in engine.stats(df)}
|
|
193
|
+
assert by_name["x"].max == 3.0
|
|
194
|
+
assert by_name["cat"].stratifiable
|
|
195
|
+
|
|
196
|
+
|
|
138
197
|
def test_sample_all_when_count_exceeds_rows(engine):
|
|
139
198
|
small = pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
|
|
140
199
|
result = engine.sample(small, 10)
|
|
@@ -53,6 +53,16 @@ def test_exclude_columns_are_not_stratified(demo_df):
|
|
|
53
53
|
assert "active" not in result.strat_cols
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
def test_continuous_numeric_not_auto_selected(demo_df):
|
|
57
|
+
# 4 unique values would pass the cardinality gate, but the fractional
|
|
58
|
+
# values mark the column as continuous → skipped by default
|
|
59
|
+
df = demo_df.copy()
|
|
60
|
+
df["price"] = np.tile([9.99, 19.99, 29.99, 39.99], len(df) // 4)
|
|
61
|
+
cols = find_stratification_columns(df, 400)
|
|
62
|
+
assert "price" not in cols
|
|
63
|
+
assert "region" in cols # the categorical candidates are unaffected
|
|
64
|
+
|
|
65
|
+
|
|
56
66
|
def test_exclude_all_falls_back_to_random(demo_df):
|
|
57
67
|
result = sample(
|
|
58
68
|
demo_df, 100, exclude_columns=list(demo_df.columns), random_state=3
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import numpy as np
|
|
2
2
|
import pandas as pd
|
|
3
|
+
import pytest
|
|
3
4
|
|
|
4
5
|
from data_sampler.stats import (
|
|
5
6
|
ColumnStats,
|
|
@@ -91,6 +92,83 @@ def test_single_value_column_not_stratifiable():
|
|
|
91
92
|
assert not is_stratifiable(series, 50)
|
|
92
93
|
|
|
93
94
|
|
|
95
|
+
def test_continuous_numeric_not_stratifiable():
|
|
96
|
+
# low cardinality but fractional values → continuous, skipped by default
|
|
97
|
+
series = pd.Series([9.99, 19.99, 29.99, 39.99] * 25, name="price")
|
|
98
|
+
assert not is_stratifiable(series, 100)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_decimal_object_column_rules_like_the_engine():
|
|
102
|
+
# parquet DECIMAL / SQL drivers arrive as object-of-Decimal: the fractional
|
|
103
|
+
# rule is value-based, so these must rule identically to the DuckDB probe
|
|
104
|
+
from decimal import Decimal
|
|
105
|
+
|
|
106
|
+
frac = pd.Series(
|
|
107
|
+
[Decimal("9.99"), Decimal("19.99"), Decimal("29.99"), Decimal("39.99")] * 25,
|
|
108
|
+
name="price",
|
|
109
|
+
)
|
|
110
|
+
assert not is_stratifiable(frac, 100)
|
|
111
|
+
whole = pd.Series([Decimal("1"), Decimal("2"), Decimal("3")] * 34, name="code")
|
|
112
|
+
assert is_stratifiable(whole, 102)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_float_backed_categorical_rules_like_the_engine():
|
|
116
|
+
# DuckDB registration flattens category-of-float to DOUBLE; the pandas
|
|
117
|
+
# side must rule on the category values the same way
|
|
118
|
+
frac = pd.Series([1.5, 2.5, 3.5, 4.5] * 25, name="band").astype("category")
|
|
119
|
+
assert not is_stratifiable(frac, 100)
|
|
120
|
+
whole = pd.Series([1.0, 2.0, 3.0, 4.0] * 25, name="code").astype("category")
|
|
121
|
+
assert is_stratifiable(whole, 100)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_unused_fractional_category_ignored():
|
|
125
|
+
# row-filtering keeps unused categories around; the rule must judge
|
|
126
|
+
# observed values only — all the engine ever sees after registration
|
|
127
|
+
s = pd.Series(
|
|
128
|
+
pd.Categorical([1.0, 2.0] * 50, categories=[1.0, 2.0, 3.5]), name="band"
|
|
129
|
+
)
|
|
130
|
+
assert is_stratifiable(s, 100)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_arrow_decimal_column_rules_like_the_engine():
|
|
134
|
+
# read_parquet(dtype_backend='pyarrow') yields ArrowDtype(decimal128) for
|
|
135
|
+
# parquet DECIMAL — same verdict as the engine's DECIMAL probe required
|
|
136
|
+
pa = pytest.importorskip("pyarrow")
|
|
137
|
+
from decimal import Decimal
|
|
138
|
+
|
|
139
|
+
frac = pd.Series(
|
|
140
|
+
[Decimal("9.99"), Decimal("19.99"), Decimal("29.99")] * 34,
|
|
141
|
+
dtype=pd.ArrowDtype(pa.decimal128(6, 2)),
|
|
142
|
+
name="price",
|
|
143
|
+
)
|
|
144
|
+
assert not is_stratifiable(frac, 102)
|
|
145
|
+
whole = pd.Series(
|
|
146
|
+
[Decimal("10"), Decimal("20"), Decimal("30")] * 34,
|
|
147
|
+
dtype=pd.ArrowDtype(pa.decimal128(6, 2)),
|
|
148
|
+
name="code",
|
|
149
|
+
)
|
|
150
|
+
assert is_stratifiable(whole, 102)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_object_float_column_fractional_not_stratifiable():
|
|
154
|
+
frac = pd.Series([9.99, 19.99, 29.99, 39.99] * 25, dtype=object, name="price")
|
|
155
|
+
assert not is_stratifiable(frac, 100)
|
|
156
|
+
mixed = pd.Series(["a", "b", 1.5, 2.5] * 25, dtype=object, name="junk")
|
|
157
|
+
assert is_stratifiable(mixed, 100) # not number-valued → old rules apply
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_whole_number_float_still_stratifiable():
|
|
161
|
+
# integer-coded columns arrive as float64 once NaNs appear — they stay
|
|
162
|
+
# candidates as long as every value is a whole number
|
|
163
|
+
series = pd.Series([1.0, 2.0, 3.0, np.nan] * 25, name="rating")
|
|
164
|
+
assert is_stratifiable(series, 100)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_discrete_int_column_stratifiable():
|
|
168
|
+
series = pd.Series([1, 2, 3, 4, 5] * 20, name="stars")
|
|
169
|
+
assert is_stratifiable(series, 100)
|
|
170
|
+
|
|
171
|
+
|
|
94
172
|
def test_boolean_column_kind(demo_df):
|
|
95
173
|
assert compute_column_stats(demo_df["active"]).kind == "boolean"
|
|
96
174
|
|
|
@@ -584,6 +584,31 @@ def test_non_numeric_skip_reduce_shows_dash(csv_file):
|
|
|
584
584
|
run(go())
|
|
585
585
|
|
|
586
586
|
|
|
587
|
+
def test_reduce_skip_toggle_hidden_for_non_numeric(csv_file):
|
|
588
|
+
"""PCA reduction only ever touches continuous numeric columns, so the
|
|
589
|
+
'skip from reduction (PCA)' row must be shown for numeric columns and
|
|
590
|
+
hidden for string / categorical / boolean columns."""
|
|
591
|
+
async def go():
|
|
592
|
+
app = DataSamplerApp(path=str(csv_file))
|
|
593
|
+
async with app.run_test(size=(160, 45)) as pilot:
|
|
594
|
+
screen = await wait_for_screen(app, pilot, ColumnsScreen)
|
|
595
|
+
row = screen.query_one("#reduce-skip-row")
|
|
596
|
+
|
|
597
|
+
screen._sync_config_panel("score") # numeric → shown
|
|
598
|
+
assert not row.has_class("hidden")
|
|
599
|
+
|
|
600
|
+
screen._sync_config_panel("region") # categorical → hidden
|
|
601
|
+
assert row.has_class("hidden")
|
|
602
|
+
|
|
603
|
+
screen._sync_config_panel("active") # boolean → hidden
|
|
604
|
+
assert row.has_class("hidden")
|
|
605
|
+
|
|
606
|
+
screen._sync_config_panel("id") # numeric again → shown
|
|
607
|
+
assert not row.has_class("hidden")
|
|
608
|
+
|
|
609
|
+
run(go())
|
|
610
|
+
|
|
611
|
+
|
|
587
612
|
def test_refresh_browser_action(csv_file):
|
|
588
613
|
async def go():
|
|
589
614
|
app = DataSamplerApp()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|