data-sampler 3.2.1__tar.gz → 3.4.0__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.
Files changed (42) hide show
  1. {data_sampler-3.2.1 → data_sampler-3.4.0}/CHANGELOG.md +64 -0
  2. {data_sampler-3.2.1 → data_sampler-3.4.0}/PKG-INFO +180 -16
  3. {data_sampler-3.2.1 → data_sampler-3.4.0}/README.md +178 -14
  4. {data_sampler-3.2.1 → data_sampler-3.4.0}/RELEASING.md +13 -17
  5. {data_sampler-3.2.1 → data_sampler-3.4.0}/ROADMAP.md +10 -0
  6. {data_sampler-3.2.1 → data_sampler-3.4.0}/TROUBLESHOOTING.md +57 -0
  7. {data_sampler-3.2.1 → data_sampler-3.4.0}/pyproject.toml +2 -2
  8. {data_sampler-3.2.1 → data_sampler-3.4.0}/src/data_sampler/__init__.py +9 -2
  9. {data_sampler-3.2.1 → data_sampler-3.4.0}/src/data_sampler/cli.py +135 -3
  10. {data_sampler-3.2.1 → data_sampler-3.4.0}/src/data_sampler/engine.py +121 -21
  11. data_sampler-3.4.0/src/data_sampler/reduce.py +343 -0
  12. {data_sampler-3.2.1 → data_sampler-3.4.0}/src/data_sampler/report.py +76 -0
  13. {data_sampler-3.2.1 → data_sampler-3.4.0}/src/data_sampler/tui/app.py +85 -4
  14. {data_sampler-3.2.1 → data_sampler-3.4.0}/tests/test_cli.py +136 -0
  15. {data_sampler-3.2.1 → data_sampler-3.4.0}/tests/test_engine.py +128 -0
  16. data_sampler-3.4.0/tests/test_reduce.py +291 -0
  17. {data_sampler-3.2.1 → data_sampler-3.4.0}/tests/test_tui.py +104 -6
  18. {data_sampler-3.2.1 → data_sampler-3.4.0}/.gitattributes +0 -0
  19. {data_sampler-3.2.1 → data_sampler-3.4.0}/.github/workflows/release.yml +0 -0
  20. {data_sampler-3.2.1 → data_sampler-3.4.0}/.gitignore +0 -0
  21. {data_sampler-3.2.1 → data_sampler-3.4.0}/LICENSE +0 -0
  22. {data_sampler-3.2.1 → data_sampler-3.4.0}/examples/employees.csv +0 -0
  23. {data_sampler-3.2.1 → data_sampler-3.4.0}/examples/using_data_sampler.ipynb +0 -0
  24. {data_sampler-3.2.1 → data_sampler-3.4.0}/requirements.txt +0 -0
  25. {data_sampler-3.2.1 → data_sampler-3.4.0}/scripts/run-tui.bat +0 -0
  26. {data_sampler-3.2.1 → data_sampler-3.4.0}/scripts/run-tui.sh +0 -0
  27. {data_sampler-3.2.1 → data_sampler-3.4.0}/src/data_sampler/__main__.py +0 -0
  28. {data_sampler-3.2.1 → data_sampler-3.4.0}/src/data_sampler/_logging.py +0 -0
  29. {data_sampler-3.2.1 → data_sampler-3.4.0}/src/data_sampler/_names.py +0 -0
  30. {data_sampler-3.2.1 → data_sampler-3.4.0}/src/data_sampler/anonymize.py +0 -0
  31. {data_sampler-3.2.1 → data_sampler-3.4.0}/src/data_sampler/io.py +0 -0
  32. {data_sampler-3.2.1 → data_sampler-3.4.0}/src/data_sampler/sampling.py +0 -0
  33. {data_sampler-3.2.1 → data_sampler-3.4.0}/src/data_sampler/stats.py +0 -0
  34. {data_sampler-3.2.1 → data_sampler-3.4.0}/src/data_sampler/tui/__init__.py +0 -0
  35. {data_sampler-3.2.1 → data_sampler-3.4.0}/src/data_sampler/workflow.py +0 -0
  36. {data_sampler-3.2.1 → data_sampler-3.4.0}/tests/conftest.py +0 -0
  37. {data_sampler-3.2.1 → data_sampler-3.4.0}/tests/test_anonymize.py +0 -0
  38. {data_sampler-3.2.1 → data_sampler-3.4.0}/tests/test_io.py +0 -0
  39. {data_sampler-3.2.1 → data_sampler-3.4.0}/tests/test_report.py +0 -0
  40. {data_sampler-3.2.1 → data_sampler-3.4.0}/tests/test_sampling.py +0 -0
  41. {data_sampler-3.2.1 → data_sampler-3.4.0}/tests/test_stats.py +0 -0
  42. {data_sampler-3.2.1 → data_sampler-3.4.0}/tests/test_workflow.py +0 -0
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.4.0 — 2026-07-24
4
+
5
+ - **Added optional PCA column reduction** (`data_sampler.reduce`): after
6
+ sampling (and anonymization, if any), the numeric block of the outgoing
7
+ sample can be collapsed into its first *k* principal components — so the
8
+ delivered file is narrow as well as short. *k* is controlled one of two
9
+ ways: a component count (`--reduce-components N` /
10
+ `reduce_columns(df, n_components=N)`, capped at the number of usable
11
+ numeric columns) or a variance target (`--reduce-variance R` /
12
+ `variance_ratio=R`: the fewest components whose cumulative
13
+ explained-variance ratio reaches R). Non-numeric columns are always
14
+ preserved; `--reduce-exclude` passes chosen numeric columns (e.g.
15
+ identifiers) through unchanged, `--reduce-prefix` renames the `PC*`
16
+ output columns when the source already has one, and the tool warns when
17
+ an included column looks like an identifier. Reduce flags are validated
18
+ before sampling starts, so a typo never costs a full out-of-core run. Missing values are mean-imputed (the row count
19
+ never changes), constant/all-missing columns pass through with a note, and
20
+ columns are z-scored by default (`--reduce-no-standardize` opts out) so
21
+ large-unit columns cannot dominate. The exact-SVD implementation is pure
22
+ numpy — no new dependency — and deterministic across runs and platforms
23
+ (sign-fixed singular vectors). New public API: `reduce_columns`,
24
+ `ReductionResult`, `format_reduction_report`.
25
+ - **Every reduction reports its rationale**, not just the variance kept: the
26
+ report lists per-component and cumulative explained variance, **the groups
27
+ of correlated columns that move together** (connected components of the
28
+ |r| ≥ 0.7 correlation graph — on standardized data PCA diagonalizes
29
+ exactly that correlation matrix, so the groups are a faithful account of
30
+ what compressed), each component's top driving columns, and any
31
+ imputation/passthrough/identifier notes. The full labelled correlation
32
+ matrix is available on `ReductionResult.correlation_matrix`.
33
+ - Wired into all three surfaces: CLI flags on both the pandas and DuckDB
34
+ engine paths (the reduction always runs on the small sampled frame, never
35
+ the full source), a `reduce` control in the TUI run bar with the rationale
36
+ shown on the report screen, and the Python API. Output files gain a
37
+ `_pca{k}` tag suffix (e.g. `data_sample_500_anon_pca3.csv`).
38
+
39
+ ## v3.3.1 — 2026-07-23
40
+
41
+ - **Fixed the second CI-only TUI race** (caught, again, by the release CI —
42
+ it blocked v3.3.0's PyPI publish): a stale `Changed` message (mount-time
43
+ echo or superseded edit, delivered late because every Textual widget runs
44
+ its own message pump) could roll an anonymizer choice back to `none` and
45
+ wipe its options. All Select/Input/Switch handlers now drop any `Changed`
46
+ whose value no longer matches the widget's current value — provably stale
47
+ — which also subsumes the v3.2.1 duplicate-highlight fix. v3.3.0 was never
48
+ published to PyPI; this release carries all of its changes.
49
+
50
+ ## v3.3.0 — 2026-07-23
51
+
52
+ - **Two-phase narrow sampling in the DuckDB engine.** The expensive phase of a
53
+ sample (the per-stratum window sort, or the reservoir buffer) now runs over
54
+ only the stratification columns plus a stable row id — `file_row_number` for
55
+ single-file Parquet sources, a positional id for DataFrames — and a second
56
+ pass fetches the winning rows with every column. Measured on 300k rows ×
57
+ 401 columns: stratified sampling 2.5× faster on Parquet and 9.5× faster for
58
+ DataFrame sources (whose wide payload never enters the SQL engine; winners
59
+ return as dtype-preserving pandas slices). CSV/TSV/JSON and multi-file
60
+ Parquet globs keep the previous single-pass shape (per-file row numbers are
61
+ not a global id — verification caught that a glob would otherwise be
62
+ silently over-sampled — and text formats must re-parse per scan anyway).
63
+ No behavioral change to counts, allocation, NaN strata, or determinism;
64
+ note that *which* rows a given seed selects changes vs v3.2.1 for
65
+ single-file Parquet and DataFrame sources.
66
+
3
67
  ## v3.2.1 — 2026-07-23
4
68
 
5
69
  - **Fixed a TUI race that only surfaced on slow machines** (caught by the
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: data-sampler
3
- Version: 3.2.1
4
- Summary: Representative (stratified) sampling and column anonymization for tabular data files, with a terminal UI.
3
+ Version: 3.4.0
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
7
7
  Author: aaronified
@@ -55,23 +55,51 @@ Description-Content-Type: text/markdown
55
55
 
56
56
  # data-sampler
57
57
 
58
- Creates representative samples from data files, using stratified sampling to
59
- preserve the statistical variety of your data — with optional per-column
60
- anonymization and a colorful terminal UI.
58
+ [![PyPI](https://img.shields.io/pypi/v/data-sampler)](https://pypi.org/project/data-sampler/)
59
+ [![Python](https://img.shields.io/pypi/pyversions/data-sampler)](https://pypi.org/project/data-sampler/)
61
60
 
62
- Everything ships as a single Python package: launch the TUI with one
63
- function call or command, or use the sampling/anonymization functions
64
- directly from Python.
61
+ **Hand someone data that looks and behaves like your production data, isn't
62
+ your production data, and provably kept its statistical variety — in one
63
+ command.**
64
+
65
+ ```sh
66
+ data-sampler customers.xlsx 500 --suggest
67
+ ```
68
+
69
+ Use it to:
70
+
71
+ - **Share a realistic slice with a vendor or contractor** — names, ids,
72
+ emails, salaries, and dates anonymized, but every distribution, duplicate,
73
+ and group structure intact.
74
+ - **Attach a repro to a bug report** without leaking the real records that
75
+ trigger it.
76
+ - **Cut a 2 GB export down to 500 rows** for a demo or prototype that still
77
+ behaves like the real thing.
78
+ - **Build test fixtures that mirror production skew** instead of uniform toy
79
+ data.
80
+ - **Give a class or workshop realistic data** without a data-sharing
81
+ agreement.
82
+ - **Pull 10,000 representative rows from a 100M-row Parquet file** without
83
+ loading it — the optional DuckDB engine samples out-of-core, in parallel.
84
+ - **Prove the sample is representative**: every run produces a side-by-side
85
+ source-vs-sample distribution report, per column.
86
+
87
+ How: stratified sampling preserves the statistical variety of your data
88
+ (strata are detected automatically), and every anonymizer maps each unique
89
+ original value to exactly one replacement — so repeated values stay repeated
90
+ and the joint distributions survive anonymization. Everything ships as a
91
+ single Python package: a colorful terminal UI for non-programmers, a headless
92
+ CLI, and a plain Python API.
65
93
 
66
94
  ## Install
67
95
 
68
96
  ```sh
69
- pip install data-sampler # once released on PyPI
70
- # from a clone, today:
71
- pip install -e ".[dev]"
97
+ pip install data-sampler # from PyPI
98
+ pip install "data-sampler[large]" # + the out-of-core DuckDB engine for huge files
72
99
  ```
73
100
 
74
- Requires Python 3.10+.
101
+ Requires Python 3.10+. For development, clone the repo and
102
+ `pip install -e ".[dev]"`.
75
103
 
76
104
  ## Terminal UI
77
105
 
@@ -99,7 +127,8 @@ The TUI is a panel-based dashboard (think btop / lazydocker):
99
127
  VS Code extension). Select a column to see full stats and distribution
100
128
  bars, choose an anonymizer for it, and toggle whether it should be
101
129
  skipped when preserving statistical variety (stratification). Set the
102
- sample size, output folder, optional seed, and run.
130
+ sample size, output folder, optional seed, an optional PCA column
131
+ reduction (`reduce`: N components or a variance target), and run.
103
132
  3. **Report screen** — the stratification comparison and anonymization summary
104
133
  on the left, and a **column histograms** panel on the right showing every
105
134
  column's source-vs-sample distribution (numeric columns share bin edges;
@@ -126,6 +155,11 @@ data-sampler <source> <count> [options]
126
155
  | `--anon COL=KIND[:k=v,...]` | Anonymize a column (repeatable) |
127
156
  | `-i`, `--interactive` | Guided workflow: choose an anonymizer type per column from a menu |
128
157
  | `--suggest` | Auto-assign a suggested anonymizer type to each column from its stats |
158
+ | `--reduce-components N` | PCA column reduction: replace the numeric columns with the first `N` principal components |
159
+ | `--reduce-variance R` | PCA column reduction: keep the fewest components whose cumulative explained variance reaches `R` (0 < R < 1) |
160
+ | `--reduce-exclude COL[,COL]` | Numeric column(s) to keep out of the reduction, e.g. identifiers (repeatable) |
161
+ | `--reduce-prefix PREFIX` | Name prefix for the component columns (default `PC` → `PC1`, `PC2`, …) |
162
+ | `--reduce-no-standardize` | Skip the per-column z-scoring before PCA |
129
163
  | `--engine {auto,pandas,duckdb}` | Sampling engine (default `auto`: DuckDB for Parquet/large inputs, pandas otherwise) |
130
164
  | `--threads N` | DuckDB engine: number of threads (default: all cores) |
131
165
  | `--memory-limit SIZE` | DuckDB engine: memory limit before spilling to disk (e.g. `8GB`) |
@@ -144,6 +178,11 @@ data-sampler data.csv 100 --skip region,notes --seed 7 \
144
178
 
145
179
  # large / out-of-core: sample a Parquet file in parallel with DuckDB
146
180
  data-sampler huge.parquet 10000 --engine duckdb --threads 8 --memory-limit 8GB --suggest
181
+
182
+ # narrow the sample too: collapse the numeric columns into 3 principal
183
+ # components (or keep however many retain 90% of the variance)
184
+ data-sampler wide.csv 500 --reduce-components 3 --reduce-exclude cust_id
185
+ data-sampler wide.csv 500 --reduce-variance 0.9
147
186
  ```
148
187
 
149
188
  ## Python API
@@ -176,7 +215,11 @@ anon = ds.anonymize(
176
215
  seed=7,
177
216
  )
178
217
 
179
- ds.save_output(anon, "data.xlsx", tag="sample_500_anon")
218
+ # optionally collapse the numeric columns into principal components
219
+ red = ds.reduce_columns(anon, variance_ratio=0.9, exclude=["cust_id"])
220
+ print(ds.format_reduction_report(red)) # variance kept + correlated groups
221
+
222
+ ds.save_output(red.data, "data.xlsx", tag="sample_500_anon_pca")
180
223
  ```
181
224
 
182
225
  ## Try it: bundled example
@@ -344,6 +387,34 @@ category. A side-by-side distribution report is produced for every run.
344
387
  If no suitable stratification columns exist, the tool falls back to pure
345
388
  random sampling automatically.
346
389
 
390
+ ### Reducing columns (PCA)
391
+
392
+ Sampling narrows the *rows*; the optional PCA step narrows the *columns* of
393
+ the outgoing sample. It replaces the numeric block with its first *k*
394
+ principal components (`PC1..PCk`), controlled one of two ways:
395
+
396
+ - **`--reduce-components N`** — `N` components in the output (capped at the
397
+ number of usable numeric columns, with a note when fewer are possible);
398
+ - **`--reduce-variance R`** — the fewest components whose cumulative
399
+ explained-variance ratio reaches `R` (e.g. `0.9` keeps ≥ 90 % of the
400
+ numeric variance).
401
+
402
+ Non-numeric columns (ids, categories, text, booleans, datetimes) are always
403
+ preserved, and `--reduce-exclude` keeps chosen numeric columns out too —
404
+ identifiers should be excluded, since an all-unique id forms its own
405
+ artificial component (the tool warns when it spots one). Missing values are
406
+ mean-imputed so the row count never changes; constant columns carry no signal
407
+ and pass through unchanged. Columns are z-scored first by default (PCA on the
408
+ correlation matrix), so a large-unit column such as a salary cannot dominate
409
+ the components; `--reduce-no-standardize` turns that off.
410
+
411
+ Every reduction prints its rationale: the variance each component retains,
412
+ **the groups of correlated columns** that move together (which is exactly the
413
+ redundancy PCA collapses — on standardized data PCA diagonalizes the
414
+ correlation matrix), and each component's top driving columns. The reduction
415
+ runs after anonymization, on the already-sampled rows, so the stratification
416
+ and histogram reports still describe the original columns.
417
+
347
418
  ## Large data: the out-of-core DuckDB engine
348
419
 
349
420
  The default pandas path loads the whole file into memory. For inputs that are
@@ -394,6 +465,97 @@ The pandas path on the same file: 5.6 s total while materializing a ~0.9 GB
394
465
  frame in RAM — the engine's reservoir sampling is ~50× faster and never
395
466
  materializes the source at all.
396
467
 
468
+ ## How it scales: the algorithms
469
+
470
+ Every trick used to handle millions-to-billions of rows and thousands of
471
+ columns, in one place.
472
+
473
+ ### Millions (to billions) of rows
474
+
475
+ - **Out-of-core execution.** With the DuckDB engine, loading, stratification,
476
+ and sampling run inside a vectorized, multi-threaded SQL engine with a
477
+ memory limit and a temp directory — it spills to disk instead of OOM-ing,
478
+ and only the resulting sample ever becomes a DataFrame.
479
+ - **Reservoir sampling** for the random case: a single streaming pass with
480
+ O(sample size) memory, an exact row count, and `REPEATABLE(seed)`
481
+ reproducibility that is independent of the file format and thread count.
482
+ - **Two-pass stratified sampling.** Pass 1 is a `GROUP BY` over the
483
+ stratification columns — one row per stratum, tiny regardless of source
484
+ size. Largest-remainder proportional allocation is computed on that tiny
485
+ table in numpy. Pass 2 ranks rows per stratum with
486
+ `row_number() OVER (PARTITION BY strata ORDER BY random())` and keeps the
487
+ first *allocation* rows of each, joining the allocation table with
488
+ `IS NOT DISTINCT FROM` so missing-value strata are sampled too. DuckDB
489
+ parallelizes the partitions and spills the window sort if needed.
490
+ - **Parquet projection pushdown.** Pass 1 and the stats queries touch only
491
+ the columns they reference, so a wide Parquet file is never read in full.
492
+ - **Two-phase narrow sampling.** The expensive phase (the per-stratum window
493
+ sort, or the reservoir buffer) runs over *only the stratification columns
494
+ plus a stable row id* — `file_row_number` for single-file Parquet, a
495
+ positional id for DataFrames — and a second pass fetches just the winning
496
+ rows with every column. The sort never carries the wide payload: measured
497
+ 2.5× on a 400-column Parquet file and 9.5× for wide DataFrames (whose
498
+ payload never enters the SQL engine at all — winners come back as
499
+ dtype-preserving pandas slices). CSV/JSON and multi-file Parquet globs
500
+ keep the single-pass shape, which is the correct one there (text must be
501
+ re-parsed per scan, and per-file row numbers aren't a global id).
502
+ - **Determinism engineering.** DuckDB's `GROUP BY` output order is
503
+ nondeterministic, so the stratum order is pinned with `ORDER BY … NULLS
504
+ LAST` before allocation (otherwise remainder ties break differently run to
505
+ run); seeded stratified runs drop to a single thread because `random()`
506
+ ordering is only reproducible that way, then restore the thread count.
507
+ - **Row-count caching.** `count(*)` runs once per source per engine session
508
+ instead of once per operation.
509
+ - **Vectorized anonymizers.** The column is dictionary-encoded once with
510
+ `pd.factorize`, each *unique* value gets one replacement, and the result is
511
+ assembled by a fancy-index gather — the in-process equivalent of a native
512
+ join against a mapping table. Cost scales with unique values plus one
513
+ vectorized pass, never a Python loop over rows: sequential IDs are an
514
+ `np.arange`, numeric/datetime jitter are single vectorized RNG draws.
515
+
516
+ ### Thousands of columns
517
+
518
+ - **One scan for all scalar stats.** `DuckDBEngine.stats()` computes count,
519
+ distinct, min/max/mean/std, and median for *every column in a single
520
+ aggregate query* — one streaming pass over the data regardless of how many
521
+ columns there are.
522
+ - **Sketches instead of sorts.** Distinct counts use HyperLogLog
523
+ (`approx_count_distinct`) and medians use `approx_quantile` — streaming
524
+ approximations with fixed memory per column, no per-column sort or full
525
+ hash table. Exact mode (`approximate=False`) exists for small data, and
526
+ approximate results are flagged on the stats object.
527
+ - **`distributions=False`** skips the per-column histogram/top-k passes
528
+ entirely, so very wide tables get exactly one scan (this is what the CLI's
529
+ `--suggest` uses to pick anonymizer types).
530
+ - **Bounded stratification search.** Candidate columns are screened in one
531
+ aggregate pass (HLL cardinality + average text length), then a greedy
532
+ fewest-categories-first selection keeps the *joint* stratum count at or
533
+ below the sample size — so the group count stays bounded no matter how
534
+ many columns the file has.
535
+ - **Pandas-path trims.** Numeric columns skip the top-values stringification
536
+ (~1.9× faster stats), report histograms skip near-unique columns instead
537
+ of hashing millions of ids for a meaningless top-8, and the TUI computes
538
+ stats in a worker thread so the UI never freezes on load.
539
+ - **PCA column reduction on the way out.** `--reduce-components` /
540
+ `--reduce-variance` collapse a wide numeric block into a handful of
541
+ principal components *after* sampling, so the SVD runs on the small sampled
542
+ frame (never the full source) and the delivered file is narrow as well as
543
+ short.
544
+
545
+ ### Correctness under scale
546
+
547
+ Details that only bite on real data, all regression-tested: DuckDB treats NaN
548
+ as a value rather than NULL, so every NaN-sensitive aggregate is filtered
549
+ (`FILTER (WHERE NOT isnan(…))`); missing-value strata survive the allocation
550
+ join via `IS NOT DISTINCT FROM`; HyperLogLog estimates are clamped to each
551
+ column's non-null count; and columns-oriented JSON (the pandas `to_json()`
552
+ default, which SQL engines parse as one giant row) is detected and refused
553
+ with guidance.
554
+
555
+ Known trade-offs: CSV sources are re-parsed per query (the streaming design —
556
+ convert to Parquet for repeated work), and a seeded stratified run gives up
557
+ multi-threading for reproducibility (unseeded runs use all cores).
558
+
397
559
  ## Supported formats
398
560
 
399
561
  | Format | Extensions |
@@ -406,7 +568,8 @@ materializes the source at all.
406
568
 
407
569
  Output keeps the source format and is named
408
570
  `{stem}_sample_{count}{ext}` — with an `_anon` suffix when anonymization ran
409
- (e.g. `data_sample_500_anon.csv`).
571
+ and a `_pca{k}` suffix when PCA column reduction ran
572
+ (e.g. `data_sample_500_anon_pca3.csv`).
410
573
 
411
574
  ## Development
412
575
 
@@ -420,7 +583,8 @@ Logging is controlled by `DATA_SAMPLER_LOG` (`quiet`/`info`/`verbose`) and
420
583
  `DATA_SAMPLER_LOG_FILE`. See `ROADMAP.md` for planned work and
421
584
  `TROUBLESHOOTING.md` for known failure modes.
422
585
 
423
- PyPI releases are manual and happen only after extensive testing.
586
+ Releases go to [PyPI](https://pypi.org/project/data-sampler/) via a
587
+ human-triggered, test-gated GitHub Actions workflow — see `RELEASING.md`.
424
588
 
425
589
  ---
426
590
 
@@ -1,22 +1,50 @@
1
1
  # data-sampler
2
2
 
3
- Creates representative samples from data files, using stratified sampling to
4
- preserve the statistical variety of your data — with optional per-column
5
- anonymization and a colorful terminal UI.
3
+ [![PyPI](https://img.shields.io/pypi/v/data-sampler)](https://pypi.org/project/data-sampler/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/data-sampler)](https://pypi.org/project/data-sampler/)
6
5
 
7
- Everything ships as a single Python package: launch the TUI with one
8
- function call or command, or use the sampling/anonymization functions
9
- directly from Python.
6
+ **Hand someone data that looks and behaves like your production data, isn't
7
+ your production data, and provably kept its statistical variety — in one
8
+ command.**
9
+
10
+ ```sh
11
+ data-sampler customers.xlsx 500 --suggest
12
+ ```
13
+
14
+ Use it to:
15
+
16
+ - **Share a realistic slice with a vendor or contractor** — names, ids,
17
+ emails, salaries, and dates anonymized, but every distribution, duplicate,
18
+ and group structure intact.
19
+ - **Attach a repro to a bug report** without leaking the real records that
20
+ trigger it.
21
+ - **Cut a 2 GB export down to 500 rows** for a demo or prototype that still
22
+ behaves like the real thing.
23
+ - **Build test fixtures that mirror production skew** instead of uniform toy
24
+ data.
25
+ - **Give a class or workshop realistic data** without a data-sharing
26
+ agreement.
27
+ - **Pull 10,000 representative rows from a 100M-row Parquet file** without
28
+ loading it — the optional DuckDB engine samples out-of-core, in parallel.
29
+ - **Prove the sample is representative**: every run produces a side-by-side
30
+ source-vs-sample distribution report, per column.
31
+
32
+ How: stratified sampling preserves the statistical variety of your data
33
+ (strata are detected automatically), and every anonymizer maps each unique
34
+ original value to exactly one replacement — so repeated values stay repeated
35
+ and the joint distributions survive anonymization. Everything ships as a
36
+ single Python package: a colorful terminal UI for non-programmers, a headless
37
+ CLI, and a plain Python API.
10
38
 
11
39
  ## Install
12
40
 
13
41
  ```sh
14
- pip install data-sampler # once released on PyPI
15
- # from a clone, today:
16
- pip install -e ".[dev]"
42
+ pip install data-sampler # from PyPI
43
+ pip install "data-sampler[large]" # + the out-of-core DuckDB engine for huge files
17
44
  ```
18
45
 
19
- Requires Python 3.10+.
46
+ Requires Python 3.10+. For development, clone the repo and
47
+ `pip install -e ".[dev]"`.
20
48
 
21
49
  ## Terminal UI
22
50
 
@@ -44,7 +72,8 @@ The TUI is a panel-based dashboard (think btop / lazydocker):
44
72
  VS Code extension). Select a column to see full stats and distribution
45
73
  bars, choose an anonymizer for it, and toggle whether it should be
46
74
  skipped when preserving statistical variety (stratification). Set the
47
- sample size, output folder, optional seed, and run.
75
+ sample size, output folder, optional seed, an optional PCA column
76
+ reduction (`reduce`: N components or a variance target), and run.
48
77
  3. **Report screen** — the stratification comparison and anonymization summary
49
78
  on the left, and a **column histograms** panel on the right showing every
50
79
  column's source-vs-sample distribution (numeric columns share bin edges;
@@ -71,6 +100,11 @@ data-sampler <source> <count> [options]
71
100
  | `--anon COL=KIND[:k=v,...]` | Anonymize a column (repeatable) |
72
101
  | `-i`, `--interactive` | Guided workflow: choose an anonymizer type per column from a menu |
73
102
  | `--suggest` | Auto-assign a suggested anonymizer type to each column from its stats |
103
+ | `--reduce-components N` | PCA column reduction: replace the numeric columns with the first `N` principal components |
104
+ | `--reduce-variance R` | PCA column reduction: keep the fewest components whose cumulative explained variance reaches `R` (0 < R < 1) |
105
+ | `--reduce-exclude COL[,COL]` | Numeric column(s) to keep out of the reduction, e.g. identifiers (repeatable) |
106
+ | `--reduce-prefix PREFIX` | Name prefix for the component columns (default `PC` → `PC1`, `PC2`, …) |
107
+ | `--reduce-no-standardize` | Skip the per-column z-scoring before PCA |
74
108
  | `--engine {auto,pandas,duckdb}` | Sampling engine (default `auto`: DuckDB for Parquet/large inputs, pandas otherwise) |
75
109
  | `--threads N` | DuckDB engine: number of threads (default: all cores) |
76
110
  | `--memory-limit SIZE` | DuckDB engine: memory limit before spilling to disk (e.g. `8GB`) |
@@ -89,6 +123,11 @@ data-sampler data.csv 100 --skip region,notes --seed 7 \
89
123
 
90
124
  # large / out-of-core: sample a Parquet file in parallel with DuckDB
91
125
  data-sampler huge.parquet 10000 --engine duckdb --threads 8 --memory-limit 8GB --suggest
126
+
127
+ # narrow the sample too: collapse the numeric columns into 3 principal
128
+ # components (or keep however many retain 90% of the variance)
129
+ data-sampler wide.csv 500 --reduce-components 3 --reduce-exclude cust_id
130
+ data-sampler wide.csv 500 --reduce-variance 0.9
92
131
  ```
93
132
 
94
133
  ## Python API
@@ -121,7 +160,11 @@ anon = ds.anonymize(
121
160
  seed=7,
122
161
  )
123
162
 
124
- ds.save_output(anon, "data.xlsx", tag="sample_500_anon")
163
+ # optionally collapse the numeric columns into principal components
164
+ red = ds.reduce_columns(anon, variance_ratio=0.9, exclude=["cust_id"])
165
+ print(ds.format_reduction_report(red)) # variance kept + correlated groups
166
+
167
+ ds.save_output(red.data, "data.xlsx", tag="sample_500_anon_pca")
125
168
  ```
126
169
 
127
170
  ## Try it: bundled example
@@ -289,6 +332,34 @@ category. A side-by-side distribution report is produced for every run.
289
332
  If no suitable stratification columns exist, the tool falls back to pure
290
333
  random sampling automatically.
291
334
 
335
+ ### Reducing columns (PCA)
336
+
337
+ Sampling narrows the *rows*; the optional PCA step narrows the *columns* of
338
+ the outgoing sample. It replaces the numeric block with its first *k*
339
+ principal components (`PC1..PCk`), controlled one of two ways:
340
+
341
+ - **`--reduce-components N`** — `N` components in the output (capped at the
342
+ number of usable numeric columns, with a note when fewer are possible);
343
+ - **`--reduce-variance R`** — the fewest components whose cumulative
344
+ explained-variance ratio reaches `R` (e.g. `0.9` keeps ≥ 90 % of the
345
+ numeric variance).
346
+
347
+ Non-numeric columns (ids, categories, text, booleans, datetimes) are always
348
+ preserved, and `--reduce-exclude` keeps chosen numeric columns out too —
349
+ identifiers should be excluded, since an all-unique id forms its own
350
+ artificial component (the tool warns when it spots one). Missing values are
351
+ mean-imputed so the row count never changes; constant columns carry no signal
352
+ and pass through unchanged. Columns are z-scored first by default (PCA on the
353
+ correlation matrix), so a large-unit column such as a salary cannot dominate
354
+ the components; `--reduce-no-standardize` turns that off.
355
+
356
+ Every reduction prints its rationale: the variance each component retains,
357
+ **the groups of correlated columns** that move together (which is exactly the
358
+ redundancy PCA collapses — on standardized data PCA diagonalizes the
359
+ correlation matrix), and each component's top driving columns. The reduction
360
+ runs after anonymization, on the already-sampled rows, so the stratification
361
+ and histogram reports still describe the original columns.
362
+
292
363
  ## Large data: the out-of-core DuckDB engine
293
364
 
294
365
  The default pandas path loads the whole file into memory. For inputs that are
@@ -339,6 +410,97 @@ The pandas path on the same file: 5.6 s total while materializing a ~0.9 GB
339
410
  frame in RAM — the engine's reservoir sampling is ~50× faster and never
340
411
  materializes the source at all.
341
412
 
413
+ ## How it scales: the algorithms
414
+
415
+ Every trick used to handle millions-to-billions of rows and thousands of
416
+ columns, in one place.
417
+
418
+ ### Millions (to billions) of rows
419
+
420
+ - **Out-of-core execution.** With the DuckDB engine, loading, stratification,
421
+ and sampling run inside a vectorized, multi-threaded SQL engine with a
422
+ memory limit and a temp directory — it spills to disk instead of OOM-ing,
423
+ and only the resulting sample ever becomes a DataFrame.
424
+ - **Reservoir sampling** for the random case: a single streaming pass with
425
+ O(sample size) memory, an exact row count, and `REPEATABLE(seed)`
426
+ reproducibility that is independent of the file format and thread count.
427
+ - **Two-pass stratified sampling.** Pass 1 is a `GROUP BY` over the
428
+ stratification columns — one row per stratum, tiny regardless of source
429
+ size. Largest-remainder proportional allocation is computed on that tiny
430
+ table in numpy. Pass 2 ranks rows per stratum with
431
+ `row_number() OVER (PARTITION BY strata ORDER BY random())` and keeps the
432
+ first *allocation* rows of each, joining the allocation table with
433
+ `IS NOT DISTINCT FROM` so missing-value strata are sampled too. DuckDB
434
+ parallelizes the partitions and spills the window sort if needed.
435
+ - **Parquet projection pushdown.** Pass 1 and the stats queries touch only
436
+ the columns they reference, so a wide Parquet file is never read in full.
437
+ - **Two-phase narrow sampling.** The expensive phase (the per-stratum window
438
+ sort, or the reservoir buffer) runs over *only the stratification columns
439
+ plus a stable row id* — `file_row_number` for single-file Parquet, a
440
+ positional id for DataFrames — and a second pass fetches just the winning
441
+ rows with every column. The sort never carries the wide payload: measured
442
+ 2.5× on a 400-column Parquet file and 9.5× for wide DataFrames (whose
443
+ payload never enters the SQL engine at all — winners come back as
444
+ dtype-preserving pandas slices). CSV/JSON and multi-file Parquet globs
445
+ keep the single-pass shape, which is the correct one there (text must be
446
+ re-parsed per scan, and per-file row numbers aren't a global id).
447
+ - **Determinism engineering.** DuckDB's `GROUP BY` output order is
448
+ nondeterministic, so the stratum order is pinned with `ORDER BY … NULLS
449
+ LAST` before allocation (otherwise remainder ties break differently run to
450
+ run); seeded stratified runs drop to a single thread because `random()`
451
+ ordering is only reproducible that way, then restore the thread count.
452
+ - **Row-count caching.** `count(*)` runs once per source per engine session
453
+ instead of once per operation.
454
+ - **Vectorized anonymizers.** The column is dictionary-encoded once with
455
+ `pd.factorize`, each *unique* value gets one replacement, and the result is
456
+ assembled by a fancy-index gather — the in-process equivalent of a native
457
+ join against a mapping table. Cost scales with unique values plus one
458
+ vectorized pass, never a Python loop over rows: sequential IDs are an
459
+ `np.arange`, numeric/datetime jitter are single vectorized RNG draws.
460
+
461
+ ### Thousands of columns
462
+
463
+ - **One scan for all scalar stats.** `DuckDBEngine.stats()` computes count,
464
+ distinct, min/max/mean/std, and median for *every column in a single
465
+ aggregate query* — one streaming pass over the data regardless of how many
466
+ columns there are.
467
+ - **Sketches instead of sorts.** Distinct counts use HyperLogLog
468
+ (`approx_count_distinct`) and medians use `approx_quantile` — streaming
469
+ approximations with fixed memory per column, no per-column sort or full
470
+ hash table. Exact mode (`approximate=False`) exists for small data, and
471
+ approximate results are flagged on the stats object.
472
+ - **`distributions=False`** skips the per-column histogram/top-k passes
473
+ entirely, so very wide tables get exactly one scan (this is what the CLI's
474
+ `--suggest` uses to pick anonymizer types).
475
+ - **Bounded stratification search.** Candidate columns are screened in one
476
+ aggregate pass (HLL cardinality + average text length), then a greedy
477
+ fewest-categories-first selection keeps the *joint* stratum count at or
478
+ below the sample size — so the group count stays bounded no matter how
479
+ many columns the file has.
480
+ - **Pandas-path trims.** Numeric columns skip the top-values stringification
481
+ (~1.9× faster stats), report histograms skip near-unique columns instead
482
+ of hashing millions of ids for a meaningless top-8, and the TUI computes
483
+ stats in a worker thread so the UI never freezes on load.
484
+ - **PCA column reduction on the way out.** `--reduce-components` /
485
+ `--reduce-variance` collapse a wide numeric block into a handful of
486
+ principal components *after* sampling, so the SVD runs on the small sampled
487
+ frame (never the full source) and the delivered file is narrow as well as
488
+ short.
489
+
490
+ ### Correctness under scale
491
+
492
+ Details that only bite on real data, all regression-tested: DuckDB treats NaN
493
+ as a value rather than NULL, so every NaN-sensitive aggregate is filtered
494
+ (`FILTER (WHERE NOT isnan(…))`); missing-value strata survive the allocation
495
+ join via `IS NOT DISTINCT FROM`; HyperLogLog estimates are clamped to each
496
+ column's non-null count; and columns-oriented JSON (the pandas `to_json()`
497
+ default, which SQL engines parse as one giant row) is detected and refused
498
+ with guidance.
499
+
500
+ Known trade-offs: CSV sources are re-parsed per query (the streaming design —
501
+ convert to Parquet for repeated work), and a seeded stratified run gives up
502
+ multi-threading for reproducibility (unseeded runs use all cores).
503
+
342
504
  ## Supported formats
343
505
 
344
506
  | Format | Extensions |
@@ -351,7 +513,8 @@ materializes the source at all.
351
513
 
352
514
  Output keeps the source format and is named
353
515
  `{stem}_sample_{count}{ext}` — with an `_anon` suffix when anonymization ran
354
- (e.g. `data_sample_500_anon.csv`).
516
+ and a `_pca{k}` suffix when PCA column reduction ran
517
+ (e.g. `data_sample_500_anon_pca3.csv`).
355
518
 
356
519
  ## Development
357
520
 
@@ -365,7 +528,8 @@ Logging is controlled by `DATA_SAMPLER_LOG` (`quiet`/`info`/`verbose`) and
365
528
  `DATA_SAMPLER_LOG_FILE`. See `ROADMAP.md` for planned work and
366
529
  `TROUBLESHOOTING.md` for known failure modes.
367
530
 
368
- PyPI releases are manual and happen only after extensive testing.
531
+ Releases go to [PyPI](https://pypi.org/project/data-sampler/) via a
532
+ human-triggered, test-gated GitHub Actions workflow — see `RELEASING.md`.
369
533
 
370
534
  ---
371
535
 
@@ -5,23 +5,19 @@ human publishes a GitHub Release (or manually dispatches the workflow). The
5
5
  workflow uses **PyPI Trusted Publishing** (OIDC), so no API token is stored in
6
6
  the repo, in CI secrets, or anywhere else.
7
7
 
8
- ## One-time setup (PyPI side)
9
-
10
- 1. Create a PyPI account at <https://pypi.org/account/register/> and enable 2FA.
11
- 2. Because `data-sampler` has no release on PyPI yet, register a **pending
12
- publisher**: <https://pypi.org/manage/account/publishing/> "Add a new
13
- pending publisher" with:
14
- - PyPI project name: `data-sampler`
15
- - Owner: `aaronified`
16
- - Repository: `data-sampler`
17
- - Workflow name: `release.yml`
18
- - Environment name: `pypi`
19
- 3. (Recommended) In GitHub: Settings Environments create `pypi` and add
20
- yourself as a required reviewer. The publish job then waits for your
21
- click-to-approve on every release.
22
-
23
- After the first successful publish, the pending publisher becomes the project's
24
- trusted publisher automatically.
8
+ ## One-time setup (PyPI side) — DONE (v3.2.1, 2026-07-23)
9
+
10
+ Completed: the PyPI account exists, and the pending publisher (project
11
+ `data-sampler`, owner `aaronified`, repo `data-sampler`, workflow
12
+ `release.yml`, environment `pypi`) became the project's permanent **trusted
13
+ publisher** with the first publish. Nothing to repeat per release.
14
+
15
+ If it ever needs re-registering (new repo/owner/workflow name), manage it at
16
+ <https://pypi.org/manage/project/data-sampler/settings/publishing/>.
17
+
18
+ Still recommended: in GitHub → Settings → Environments → `pypi`, add yourself
19
+ as a required reviewer so every publish waits for your click-to-approve
20
+ (currently publishes run unattended once tests pass).
25
21
 
26
22
  ## Per release
27
23
 
@@ -89,9 +89,19 @@ its own commit.
89
89
  the per-column passes for a single cheap scalar pass over very wide inputs.
90
90
  `approx_count_distinct` also powers the engine's stratification-column selection.
91
91
 
92
+ ## v3.3
93
+
94
+ - [x] **Block P7 — two-phase narrow sampling.** Rank/reservoir over only the
95
+ stratification columns + a stable row id (single-file Parquet
96
+ `file_row_number`; DataFrame positional id), then fetch the winning full
97
+ rows. 2.5× on wide Parquet, 9.5× on wide DataFrames. CSV/JSON and Parquet
98
+ globs keep the single-pass shape (per-file row ids are not global).
99
+
92
100
  ## Later
93
101
 
94
102
  - Optional PyInstaller EXE build of the TUI (replaces the old Tkinter EXE).
103
+ - Composite filename+row-number id to extend narrow sampling to Parquet globs.
104
+ - `--columns` output projection (sample only a subset of columns).
95
105
  - Rust/Polars-on-Arrow native engine (pyo3) as an alternative to DuckDB.
96
106
  - GPU acceleration (RAPIDS cuDF) for the aggregation-heavy paths.
97
107
  - Distributed backend (Dask / Ray Data / Spark) for multi-machine scale.