synthetic-tabular 0.1.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.
- synthetic_tabular-0.1.0/LICENSE +21 -0
- synthetic_tabular-0.1.0/PKG-INFO +130 -0
- synthetic_tabular-0.1.0/README.md +102 -0
- synthetic_tabular-0.1.0/pyproject.toml +54 -0
- synthetic_tabular-0.1.0/src/synthetic_tabular/__init__.py +18 -0
- synthetic_tabular-0.1.0/src/synthetic_tabular/_columns.py +311 -0
- synthetic_tabular-0.1.0/src/synthetic_tabular/_io.py +87 -0
- synthetic_tabular-0.1.0/src/synthetic_tabular/cli.py +100 -0
- synthetic_tabular-0.1.0/src/synthetic_tabular/evaluate.py +281 -0
- synthetic_tabular-0.1.0/src/synthetic_tabular/synthesizer.py +289 -0
- synthetic_tabular-0.1.0/tests/conftest.py +24 -0
- synthetic_tabular-0.1.0/tests/test_cli.py +138 -0
- synthetic_tabular-0.1.0/tests/test_edge_cases.py +165 -0
- synthetic_tabular-0.1.0/tests/test_evaluate.py +114 -0
- synthetic_tabular-0.1.0/tests/test_quickstart.py +20 -0
- synthetic_tabular-0.1.0/tests/test_synthesizer.py +235 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pranay Mahendrakar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: synthetic-tabular
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate realistic synthetic tabular data that preserves distributions and correlations, without a GPU
|
|
5
|
+
Project-URL: Homepage, https://pypi.org/project/synthetic-tabular/
|
|
6
|
+
Project-URL: Author, https://pypi.org/user/pranaymahendrakar/
|
|
7
|
+
Author: Pranay Mahendrakar
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: data augmentation,gaussian copula,pandas,privacy,synthetic data,tabular data,test data
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: numpy>=1.23
|
|
20
|
+
Requires-Dist: pandas>=1.5
|
|
21
|
+
Requires-Dist: scipy>=1.9
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pyarrow>=12; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
25
|
+
Provides-Extra: parquet
|
|
26
|
+
Requires-Dist: pyarrow>=12; extra == 'parquet'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# synthetic-tabular
|
|
30
|
+
|
|
31
|
+
Generate realistic synthetic tabular data that keeps the distributions and correlations of your real table, so you can share, test and prototype without handing out the original rows, and without a GPU.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
pip install synthetic-tabular
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import pandas as pd
|
|
43
|
+
import synthetic_tabular as st
|
|
44
|
+
|
|
45
|
+
df = pd.DataFrame({"age": [23, 35, 41, 29, 52, 38, 45, 31],
|
|
46
|
+
"city": ["Pune", "Delhi", "Pune", "Mumbai", "Delhi", "Pune", "Mumbai", "Delhi"],
|
|
47
|
+
"spend": [120.5, 340.0, 410.2, 210.0, 620.9, 380.4, 455.0, 290.7]})
|
|
48
|
+
synthetic = st.generate(df, n=100, random_state=0) # same columns and dtypes, 100 new rows
|
|
49
|
+
print(st.evaluate(df, synthetic).summary()) # how close are the distributions?
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## What it does
|
|
53
|
+
|
|
54
|
+
- Fits a **Gaussian copula**: every column is rank-transformed to standard-normal
|
|
55
|
+
scores, the correlation matrix of those scores is learned (repaired to the nearest
|
|
56
|
+
positive-definite matrix when needed), new correlated normals are sampled and mapped
|
|
57
|
+
back through each column's own distribution.
|
|
58
|
+
- **Numeric columns** are inverted through their empirical quantile function, so the
|
|
59
|
+
output has the same shape, stays inside the observed min/max, keeps integer columns
|
|
60
|
+
integer and keeps the number of decimals seen in the input.
|
|
61
|
+
- **Categorical, boolean and string columns** are sampled by their observed
|
|
62
|
+
frequencies; booleans stay boolean, `category` dtypes keep their categories.
|
|
63
|
+
- **Datetime and timedelta columns** are modelled as nanoseconds, converted back to the
|
|
64
|
+
original dtype (time zone included) and rounded to the finest resolution seen in the
|
|
65
|
+
column (days stay days, seconds stay seconds).
|
|
66
|
+
- **Missing values** are reinjected per column at the rate seen in the input.
|
|
67
|
+
- **Constant columns** and columns with a single category are copied as-is.
|
|
68
|
+
- **High-cardinality text** (more than 50% unique strings, for example names or free
|
|
69
|
+
text) cannot be modelled by frequency: those columns are sampled with replacement
|
|
70
|
+
from the original values and flagged with a `UserWarning`. Raise `text_threshold` to
|
|
71
|
+
model them as categories instead.
|
|
72
|
+
- Output columns and dtypes match the input; `n` can be larger than the input.
|
|
73
|
+
- Paths are accepted wherever a DataFrame is: `.csv`, `.tsv` and `.parquet`
|
|
74
|
+
(`pip install synthetic-tabular[parquet]`). CSV columns whose every value is an
|
|
75
|
+
ISO-8601 date (`2024-03-31`, `2024-03-31 10:15:00`) are parsed as datetimes so they
|
|
76
|
+
are modelled as dates rather than treated as text.
|
|
77
|
+
- Deterministic: the same `random_state` always gives the same rows.
|
|
78
|
+
- Ships an `evaluate()` fidelity check: per-column KS statistic (numeric) or total
|
|
79
|
+
variation distance (categorical), correlation-matrix difference and a 0-100 score.
|
|
80
|
+
|
|
81
|
+
Limits worth knowing: a Gaussian copula captures monotone dependence between columns,
|
|
82
|
+
not arbitrary non-linear or multi-modal joint structure, and it is not a privacy
|
|
83
|
+
guarantee. Text columns that are resampled contain original values verbatim.
|
|
84
|
+
|
|
85
|
+
## API
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
synthetic_tabular.generate(df, n=None, *, random_state=0, preserve=("marginals", "correlations")) -> DataFrame
|
|
89
|
+
```
|
|
90
|
+
One-liner for the common case. `df` is a DataFrame or a path to a `.csv` / `.parquet`
|
|
91
|
+
file; `n` defaults to `len(df)`. `preserve` chooses what to keep: drop
|
|
92
|
+
`"correlations"` to sample columns independently, drop `"marginals"` to replace the
|
|
93
|
+
empirical numeric distributions with a smooth fitted normal (clipped to the observed range).
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
synthesizer = synthetic_tabular.Synthesizer(random_state=0, *, preserve=..., text_threshold=0.5)
|
|
97
|
+
synthesizer.fit(df) # returns self
|
|
98
|
+
synthesizer.sample(n=None, *, random_state=None) # DataFrame; same rows on repeat, new seed for a new batch
|
|
99
|
+
synthesizer.summary() # text: what was learned per column
|
|
100
|
+
synthesizer.to_dict() # JSON-safe version of the above
|
|
101
|
+
```
|
|
102
|
+
After `fit`: `columns_`, `column_kinds_` (`numeric`, `datetime`, `timedelta`,
|
|
103
|
+
`categorical`, `bool`, `text`, `constant`), `high_cardinality_columns_`,
|
|
104
|
+
`correlation_` (DataFrame of the fitted normal-score correlations) and `n_rows_`.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
synthetic_tabular.evaluate(real, synthetic) -> FidelityReport
|
|
108
|
+
```
|
|
109
|
+
`FidelityReport` fields: `score` (0-100), `marginal_score`, `correlation_score`,
|
|
110
|
+
`correlation_mad` (mean absolute difference of the Spearman correlation matrices,
|
|
111
|
+
`None` with fewer than two non-constant columns), `columns` (dict of
|
|
112
|
+
`ColumnFidelity`: `name`, `kind`, `metric` `"ks"`/`"tvd"`, `value`, `score`,
|
|
113
|
+
`missing_rate_real`, `missing_rate_synthetic`), `n_real`, `n_synthetic`,
|
|
114
|
+
`missing_columns`. Methods: `summary()` (text) and `to_dict()` (JSON-safe).
|
|
115
|
+
|
|
116
|
+
## CLI
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
synthetic-tabular data.csv # generate a synthetic copy and print the fidelity report
|
|
120
|
+
synthetic-tabular data.csv --n 1000 -o synth.csv # write 1000 synthetic rows (csv, tsv or parquet)
|
|
121
|
+
synthetic-tabular data.csv --evaluate synth.csv # score an existing synthetic file
|
|
122
|
+
synthetic-tabular data.csv --json # report as JSON
|
|
123
|
+
synthetic-tabular --help
|
|
124
|
+
```
|
|
125
|
+
Options: `--random-state SEED`, `--no-correlations` (marginals only), `--quiet`.
|
|
126
|
+
Flagged text columns are reported on stderr as `note:` lines.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# synthetic-tabular
|
|
2
|
+
|
|
3
|
+
Generate realistic synthetic tabular data that keeps the distributions and correlations of your real table, so you can share, test and prototype without handing out the original rows, and without a GPU.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install synthetic-tabular
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import pandas as pd
|
|
15
|
+
import synthetic_tabular as st
|
|
16
|
+
|
|
17
|
+
df = pd.DataFrame({"age": [23, 35, 41, 29, 52, 38, 45, 31],
|
|
18
|
+
"city": ["Pune", "Delhi", "Pune", "Mumbai", "Delhi", "Pune", "Mumbai", "Delhi"],
|
|
19
|
+
"spend": [120.5, 340.0, 410.2, 210.0, 620.9, 380.4, 455.0, 290.7]})
|
|
20
|
+
synthetic = st.generate(df, n=100, random_state=0) # same columns and dtypes, 100 new rows
|
|
21
|
+
print(st.evaluate(df, synthetic).summary()) # how close are the distributions?
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## What it does
|
|
25
|
+
|
|
26
|
+
- Fits a **Gaussian copula**: every column is rank-transformed to standard-normal
|
|
27
|
+
scores, the correlation matrix of those scores is learned (repaired to the nearest
|
|
28
|
+
positive-definite matrix when needed), new correlated normals are sampled and mapped
|
|
29
|
+
back through each column's own distribution.
|
|
30
|
+
- **Numeric columns** are inverted through their empirical quantile function, so the
|
|
31
|
+
output has the same shape, stays inside the observed min/max, keeps integer columns
|
|
32
|
+
integer and keeps the number of decimals seen in the input.
|
|
33
|
+
- **Categorical, boolean and string columns** are sampled by their observed
|
|
34
|
+
frequencies; booleans stay boolean, `category` dtypes keep their categories.
|
|
35
|
+
- **Datetime and timedelta columns** are modelled as nanoseconds, converted back to the
|
|
36
|
+
original dtype (time zone included) and rounded to the finest resolution seen in the
|
|
37
|
+
column (days stay days, seconds stay seconds).
|
|
38
|
+
- **Missing values** are reinjected per column at the rate seen in the input.
|
|
39
|
+
- **Constant columns** and columns with a single category are copied as-is.
|
|
40
|
+
- **High-cardinality text** (more than 50% unique strings, for example names or free
|
|
41
|
+
text) cannot be modelled by frequency: those columns are sampled with replacement
|
|
42
|
+
from the original values and flagged with a `UserWarning`. Raise `text_threshold` to
|
|
43
|
+
model them as categories instead.
|
|
44
|
+
- Output columns and dtypes match the input; `n` can be larger than the input.
|
|
45
|
+
- Paths are accepted wherever a DataFrame is: `.csv`, `.tsv` and `.parquet`
|
|
46
|
+
(`pip install synthetic-tabular[parquet]`). CSV columns whose every value is an
|
|
47
|
+
ISO-8601 date (`2024-03-31`, `2024-03-31 10:15:00`) are parsed as datetimes so they
|
|
48
|
+
are modelled as dates rather than treated as text.
|
|
49
|
+
- Deterministic: the same `random_state` always gives the same rows.
|
|
50
|
+
- Ships an `evaluate()` fidelity check: per-column KS statistic (numeric) or total
|
|
51
|
+
variation distance (categorical), correlation-matrix difference and a 0-100 score.
|
|
52
|
+
|
|
53
|
+
Limits worth knowing: a Gaussian copula captures monotone dependence between columns,
|
|
54
|
+
not arbitrary non-linear or multi-modal joint structure, and it is not a privacy
|
|
55
|
+
guarantee. Text columns that are resampled contain original values verbatim.
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
synthetic_tabular.generate(df, n=None, *, random_state=0, preserve=("marginals", "correlations")) -> DataFrame
|
|
61
|
+
```
|
|
62
|
+
One-liner for the common case. `df` is a DataFrame or a path to a `.csv` / `.parquet`
|
|
63
|
+
file; `n` defaults to `len(df)`. `preserve` chooses what to keep: drop
|
|
64
|
+
`"correlations"` to sample columns independently, drop `"marginals"` to replace the
|
|
65
|
+
empirical numeric distributions with a smooth fitted normal (clipped to the observed range).
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
synthesizer = synthetic_tabular.Synthesizer(random_state=0, *, preserve=..., text_threshold=0.5)
|
|
69
|
+
synthesizer.fit(df) # returns self
|
|
70
|
+
synthesizer.sample(n=None, *, random_state=None) # DataFrame; same rows on repeat, new seed for a new batch
|
|
71
|
+
synthesizer.summary() # text: what was learned per column
|
|
72
|
+
synthesizer.to_dict() # JSON-safe version of the above
|
|
73
|
+
```
|
|
74
|
+
After `fit`: `columns_`, `column_kinds_` (`numeric`, `datetime`, `timedelta`,
|
|
75
|
+
`categorical`, `bool`, `text`, `constant`), `high_cardinality_columns_`,
|
|
76
|
+
`correlation_` (DataFrame of the fitted normal-score correlations) and `n_rows_`.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
synthetic_tabular.evaluate(real, synthetic) -> FidelityReport
|
|
80
|
+
```
|
|
81
|
+
`FidelityReport` fields: `score` (0-100), `marginal_score`, `correlation_score`,
|
|
82
|
+
`correlation_mad` (mean absolute difference of the Spearman correlation matrices,
|
|
83
|
+
`None` with fewer than two non-constant columns), `columns` (dict of
|
|
84
|
+
`ColumnFidelity`: `name`, `kind`, `metric` `"ks"`/`"tvd"`, `value`, `score`,
|
|
85
|
+
`missing_rate_real`, `missing_rate_synthetic`), `n_real`, `n_synthetic`,
|
|
86
|
+
`missing_columns`. Methods: `summary()` (text) and `to_dict()` (JSON-safe).
|
|
87
|
+
|
|
88
|
+
## CLI
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
synthetic-tabular data.csv # generate a synthetic copy and print the fidelity report
|
|
92
|
+
synthetic-tabular data.csv --n 1000 -o synth.csv # write 1000 synthetic rows (csv, tsv or parquet)
|
|
93
|
+
synthetic-tabular data.csv --evaluate synth.csv # score an existing synthetic file
|
|
94
|
+
synthetic-tabular data.csv --json # report as JSON
|
|
95
|
+
synthetic-tabular --help
|
|
96
|
+
```
|
|
97
|
+
Options: `--random-state SEED`, `--no-correlations` (marginals only), `--quiet`.
|
|
98
|
+
Flagged text columns are reported on stderr as `note:` lines.
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "synthetic-tabular"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Generate realistic synthetic tabular data that preserves distributions and correlations, without a GPU"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Pranay Mahendrakar" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"synthetic data",
|
|
16
|
+
"tabular data",
|
|
17
|
+
"gaussian copula",
|
|
18
|
+
"pandas",
|
|
19
|
+
"data augmentation",
|
|
20
|
+
"test data",
|
|
21
|
+
"privacy",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Intended Audience :: Science/Research",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
29
|
+
"Operating System :: OS Independent",
|
|
30
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
31
|
+
]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"pandas>=1.5",
|
|
34
|
+
"numpy>=1.23",
|
|
35
|
+
"scipy>=1.9",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
# heavy or niche deps go here, never in `dependencies`
|
|
40
|
+
parquet = ["pyarrow>=12"]
|
|
41
|
+
dev = ["pytest>=7", "pyarrow>=12"]
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
synthetic-tabular = "synthetic_tabular.cli:main"
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://pypi.org/project/synthetic-tabular/"
|
|
48
|
+
Author = "https://pypi.org/user/pranaymahendrakar/"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/synthetic_tabular"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""synthetic-tabular: realistic synthetic tabular data from a pandas DataFrame.
|
|
2
|
+
|
|
3
|
+
A Gaussian copula learns each column's distribution and the correlations between
|
|
4
|
+
columns, then samples new rows that look like the original without copying it.
|
|
5
|
+
"""
|
|
6
|
+
from .evaluate import ColumnFidelity, FidelityReport, evaluate
|
|
7
|
+
from .synthesizer import Synthesizer, generate
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Synthesizer",
|
|
13
|
+
"generate",
|
|
14
|
+
"evaluate",
|
|
15
|
+
"FidelityReport",
|
|
16
|
+
"ColumnFidelity",
|
|
17
|
+
"__version__",
|
|
18
|
+
]
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Per-column marginal models used by the Gaussian copula.
|
|
2
|
+
|
|
3
|
+
Each model knows how to turn its column into standard-normal scores for fitting
|
|
4
|
+
(``scores``) and how to turn uniform draws back into values (``inverse``), or, for
|
|
5
|
+
columns that stay outside the copula, how to draw values directly (``resample``).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pandas as pd
|
|
13
|
+
from pandas.api import types as ptypes
|
|
14
|
+
from scipy import stats
|
|
15
|
+
|
|
16
|
+
# Candidate time resolutions, coarsest first, in nanoseconds.
|
|
17
|
+
_TIME_STEPS_NS: Tuple[Tuple[str, int], ...] = (
|
|
18
|
+
("day", 86_400_000_000_000),
|
|
19
|
+
("hour", 3_600_000_000_000),
|
|
20
|
+
("minute", 60_000_000_000),
|
|
21
|
+
("second", 1_000_000_000),
|
|
22
|
+
("millisecond", 1_000_000),
|
|
23
|
+
("microsecond", 1_000),
|
|
24
|
+
("nanosecond", 1),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def temporal_int64(series: pd.Series) -> Tuple[np.ndarray, np.ndarray]:
|
|
29
|
+
"""Datetime/timedelta values as int64 nanoseconds (UTC when tz-aware) plus a validity mask."""
|
|
30
|
+
dtype = series.dtype
|
|
31
|
+
if ptypes.is_datetime64_any_dtype(dtype):
|
|
32
|
+
if getattr(dtype, "tz", None) is not None:
|
|
33
|
+
series = series.dt.tz_convert("UTC").dt.tz_localize(None)
|
|
34
|
+
values = series.to_numpy(dtype="datetime64[ns]")
|
|
35
|
+
else:
|
|
36
|
+
values = series.to_numpy(dtype="timedelta64[ns]")
|
|
37
|
+
return values.astype("int64"), ~np.isnat(values)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def to_float_array(series: pd.Series) -> np.ndarray:
|
|
41
|
+
"""Numeric, boolean, datetime or timedelta values as float64 with NaN where missing."""
|
|
42
|
+
dtype = series.dtype
|
|
43
|
+
if ptypes.is_datetime64_any_dtype(dtype) or ptypes.is_timedelta64_dtype(dtype):
|
|
44
|
+
raw, valid = temporal_int64(series)
|
|
45
|
+
out = raw.astype("float64")
|
|
46
|
+
out[~valid] = np.nan
|
|
47
|
+
return out
|
|
48
|
+
return series.to_numpy(dtype="float64", na_value=np.nan)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def normal_scores(x: np.ndarray) -> np.ndarray:
|
|
52
|
+
"""Rank-transform the finite entries of ``x`` to standard-normal scores (NaN stays NaN)."""
|
|
53
|
+
z = np.full(x.shape, np.nan)
|
|
54
|
+
mask = np.isfinite(x)
|
|
55
|
+
m = int(mask.sum())
|
|
56
|
+
if m:
|
|
57
|
+
ranks = stats.rankdata(x[mask], method="average")
|
|
58
|
+
z[mask] = stats.norm.ppf(ranks / (m + 1.0))
|
|
59
|
+
return z
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def infer_decimals(values: np.ndarray, max_decimals: int = 6) -> Optional[int]:
|
|
63
|
+
"""Fewest decimals that reproduce every value, or None when more than ``max_decimals`` are needed."""
|
|
64
|
+
if len(values) == 0:
|
|
65
|
+
return None
|
|
66
|
+
tolerance = 1e-9 * np.maximum(1.0, np.abs(values))
|
|
67
|
+
for decimals in range(max_decimals + 1):
|
|
68
|
+
if np.all(np.abs(np.round(values, decimals) - values) <= tolerance):
|
|
69
|
+
return decimals
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def infer_time_step(relative_ns: np.ndarray) -> Tuple[str, int]:
|
|
74
|
+
"""Coarsest resolution (name, nanoseconds) that every value is a whole multiple of."""
|
|
75
|
+
for name, step in _TIME_STEPS_NS:
|
|
76
|
+
if step == 1 or not np.any(relative_ns % step):
|
|
77
|
+
return name, step
|
|
78
|
+
return "nanosecond", 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ColumnModel:
|
|
82
|
+
"""Base class holding a column's name, original dtype and missing rate."""
|
|
83
|
+
|
|
84
|
+
kind: str = "column"
|
|
85
|
+
modeled: bool = False # True when the column takes part in the copula
|
|
86
|
+
|
|
87
|
+
def __init__(self, name: Any, series: pd.Series) -> None:
|
|
88
|
+
self.name = name
|
|
89
|
+
self.dtype = series.dtype
|
|
90
|
+
self.missing_rate = float(series.isna().mean()) if len(series) else 0.0
|
|
91
|
+
self.scores: Optional[np.ndarray] = None
|
|
92
|
+
|
|
93
|
+
def describe(self) -> str:
|
|
94
|
+
"""One-line human description of what was learned."""
|
|
95
|
+
return self.kind
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class NumericColumn(ColumnModel):
|
|
99
|
+
"""Numeric column: empirical quantile marginal (or a fitted normal when ``smooth``)."""
|
|
100
|
+
|
|
101
|
+
kind = "numeric"
|
|
102
|
+
modeled = True
|
|
103
|
+
|
|
104
|
+
def __init__(self, name: Any, series: pd.Series, *, smooth: bool = False) -> None:
|
|
105
|
+
super().__init__(name, series)
|
|
106
|
+
self.smooth = smooth
|
|
107
|
+
x = self._encode(series)
|
|
108
|
+
observed = x[np.isfinite(x)]
|
|
109
|
+
self.sorted_values = np.sort(observed)
|
|
110
|
+
self.vmin = float(self.sorted_values[0])
|
|
111
|
+
self.vmax = float(self.sorted_values[-1])
|
|
112
|
+
self.mean = float(observed.mean())
|
|
113
|
+
self.std = float(observed.std()) or 1.0
|
|
114
|
+
self.n_unique = int(len(np.unique(observed)))
|
|
115
|
+
self.decimals: Optional[int] = self._infer_decimals(observed)
|
|
116
|
+
self.scores = normal_scores(x)
|
|
117
|
+
|
|
118
|
+
def _encode(self, series: pd.Series) -> np.ndarray:
|
|
119
|
+
return series.to_numpy(dtype="float64", na_value=np.nan)
|
|
120
|
+
|
|
121
|
+
def _infer_decimals(self, observed: np.ndarray) -> Optional[int]:
|
|
122
|
+
if ptypes.is_integer_dtype(self.dtype):
|
|
123
|
+
return 0
|
|
124
|
+
return infer_decimals(observed)
|
|
125
|
+
|
|
126
|
+
def inverse(self, u: np.ndarray) -> Any:
|
|
127
|
+
"""Map uniform draws in (0, 1) back to column values."""
|
|
128
|
+
if self.smooth:
|
|
129
|
+
values = self.mean + self.std * stats.norm.ppf(u)
|
|
130
|
+
else:
|
|
131
|
+
m = len(self.sorted_values)
|
|
132
|
+
values = np.interp(u * (m - 1), np.arange(m), self.sorted_values)
|
|
133
|
+
return self._decode(np.clip(values, self.vmin, self.vmax))
|
|
134
|
+
|
|
135
|
+
def _decode(self, values: np.ndarray) -> Any:
|
|
136
|
+
if self.decimals is not None:
|
|
137
|
+
values = np.round(values, self.decimals)
|
|
138
|
+
return np.clip(values, self.vmin, self.vmax)
|
|
139
|
+
|
|
140
|
+
def describe(self) -> str:
|
|
141
|
+
if self.decimals == 0:
|
|
142
|
+
shape = "integer"
|
|
143
|
+
elif self.decimals is None:
|
|
144
|
+
shape = "float"
|
|
145
|
+
else:
|
|
146
|
+
shape = f"{self.decimals} decimals"
|
|
147
|
+
return f"{shape}, {self.n_unique} unique, range {self.vmin:g} to {self.vmax:g}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TemporalColumn(NumericColumn):
|
|
151
|
+
"""Datetime or timedelta column, modelled as nanoseconds since its earliest value."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, name: Any, series: pd.Series, *, smooth: bool = False) -> None:
|
|
154
|
+
self.kind = "datetime" if ptypes.is_datetime64_any_dtype(series.dtype) else "timedelta"
|
|
155
|
+
self.tz = getattr(series.dtype, "tz", None)
|
|
156
|
+
super().__init__(name, series, smooth=smooth)
|
|
157
|
+
decoded = self._decode(np.array([self.vmin, self.vmax]))
|
|
158
|
+
self.first = decoded.iloc[0]
|
|
159
|
+
self.last = decoded.iloc[1]
|
|
160
|
+
|
|
161
|
+
def _encode(self, series: pd.Series) -> np.ndarray:
|
|
162
|
+
raw, valid = temporal_int64(series)
|
|
163
|
+
self.offset = int(raw[valid].min()) if valid.any() else 0
|
|
164
|
+
relative = raw - self.offset
|
|
165
|
+
self.step_name, self.step = infer_time_step(relative[valid])
|
|
166
|
+
out = relative.astype("float64")
|
|
167
|
+
out[~valid] = np.nan
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
def _infer_decimals(self, observed: np.ndarray) -> Optional[int]:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def _decode(self, values: np.ndarray) -> pd.Series:
|
|
174
|
+
ticks = np.rint(values / self.step).astype("int64") * self.step + self.offset
|
|
175
|
+
if self.kind == "timedelta":
|
|
176
|
+
return pd.Series(pd.to_timedelta(ticks, unit="ns"))
|
|
177
|
+
index = pd.to_datetime(ticks, unit="ns")
|
|
178
|
+
if self.tz is not None:
|
|
179
|
+
index = index.tz_localize("UTC").tz_convert(self.tz)
|
|
180
|
+
return pd.Series(index)
|
|
181
|
+
|
|
182
|
+
def describe(self) -> str:
|
|
183
|
+
return (
|
|
184
|
+
f"{self.step_name} resolution, {self.n_unique} unique, "
|
|
185
|
+
f"range {self.first} to {self.last}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class CategoricalColumn(ColumnModel):
|
|
190
|
+
"""Categorical or boolean column: categories ordered by frequency, sampled by thresholds."""
|
|
191
|
+
|
|
192
|
+
modeled = True
|
|
193
|
+
|
|
194
|
+
def __init__(self, name: Any, series: pd.Series, *, kind: str = "categorical") -> None:
|
|
195
|
+
super().__init__(name, series)
|
|
196
|
+
self.kind = kind
|
|
197
|
+
present = ~series.isna().to_numpy()
|
|
198
|
+
non_null = series[present]
|
|
199
|
+
counts = non_null.value_counts(sort=False)
|
|
200
|
+
counts = counts[counts > 0]
|
|
201
|
+
order = np.lexsort((np.arange(len(counts)), -counts.to_numpy(dtype="int64")))
|
|
202
|
+
self.categories: List[Any] = [counts.index[i] for i in order]
|
|
203
|
+
probabilities = counts.to_numpy(dtype="float64")[order]
|
|
204
|
+
self.probabilities = probabilities / probabilities.sum()
|
|
205
|
+
self.cumulative = np.cumsum(self.probabilities)
|
|
206
|
+
self.cumulative[-1] = 1.0
|
|
207
|
+
self._values = np.empty(len(self.categories), dtype=object)
|
|
208
|
+
for i, category in enumerate(self.categories):
|
|
209
|
+
self._values[i] = category
|
|
210
|
+
codes = pd.Categorical(non_null, categories=self.categories).codes
|
|
211
|
+
x = np.full(len(series), np.nan)
|
|
212
|
+
x[present] = codes
|
|
213
|
+
self.scores = normal_scores(x)
|
|
214
|
+
|
|
215
|
+
def inverse(self, u: np.ndarray) -> np.ndarray:
|
|
216
|
+
"""Map uniform draws in (0, 1) to categories via cumulative frequency thresholds."""
|
|
217
|
+
idx = np.searchsorted(self.cumulative, u, side="right")
|
|
218
|
+
idx = np.minimum(idx, len(self.categories) - 1)
|
|
219
|
+
values = self._values[idx]
|
|
220
|
+
if self.kind == "bool":
|
|
221
|
+
return values.astype(bool)
|
|
222
|
+
return values
|
|
223
|
+
|
|
224
|
+
def describe(self) -> str:
|
|
225
|
+
top = self.categories[0]
|
|
226
|
+
if isinstance(top, np.generic): # plain Python repr, not np.True_ / np.int64(3)
|
|
227
|
+
top = top.item()
|
|
228
|
+
return (
|
|
229
|
+
f"{len(self.categories)} categories, most common {top!r} "
|
|
230
|
+
f"({self.probabilities[0]:.0%})"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class TextColumn(ColumnModel):
|
|
235
|
+
"""High-cardinality text column: values are resampled with replacement from the originals."""
|
|
236
|
+
|
|
237
|
+
kind = "text"
|
|
238
|
+
|
|
239
|
+
def __init__(self, name: Any, series: pd.Series, *, unique_ratio: float) -> None:
|
|
240
|
+
super().__init__(name, series)
|
|
241
|
+
self.values = series.dropna().to_numpy(dtype=object)
|
|
242
|
+
self.unique_ratio = float(unique_ratio)
|
|
243
|
+
|
|
244
|
+
def resample(self, n: int, rng: np.random.Generator) -> Any:
|
|
245
|
+
if len(self.values) == 0:
|
|
246
|
+
return pd.Series([None] * n, dtype=object)
|
|
247
|
+
return self.values[rng.integers(0, len(self.values), size=n)]
|
|
248
|
+
|
|
249
|
+
def describe(self) -> str:
|
|
250
|
+
ratio = "unhashable values" if np.isnan(self.unique_ratio) else f"{self.unique_ratio:.0%} unique"
|
|
251
|
+
return f"{ratio}, resampled from the {len(self.values)} original values"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class ConstantColumn(ColumnModel):
|
|
255
|
+
"""Column with at most one distinct value (possibly all missing)."""
|
|
256
|
+
|
|
257
|
+
kind = "constant"
|
|
258
|
+
|
|
259
|
+
def __init__(self, name: Any, series: pd.Series, *, value: Any = None) -> None:
|
|
260
|
+
super().__init__(name, series)
|
|
261
|
+
self.value = value
|
|
262
|
+
|
|
263
|
+
def resample(self, n: int, rng: np.random.Generator) -> pd.Series:
|
|
264
|
+
return pd.Series([self.value] * n, dtype=object)
|
|
265
|
+
|
|
266
|
+
def describe(self) -> str:
|
|
267
|
+
return "all missing" if self.value is None else f"constant {self.value!r}"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def build_column_model(
|
|
271
|
+
name: Any, series: pd.Series, *, smooth: bool = False, text_threshold: float = 0.5
|
|
272
|
+
) -> ColumnModel:
|
|
273
|
+
"""Pick and fit the right model for one column."""
|
|
274
|
+
dtype = series.dtype
|
|
275
|
+
non_null = series.dropna()
|
|
276
|
+
if ptypes.is_bool_dtype(dtype):
|
|
277
|
+
kind = "bool"
|
|
278
|
+
elif ptypes.is_datetime64_any_dtype(dtype) or ptypes.is_timedelta64_dtype(dtype):
|
|
279
|
+
kind = "temporal"
|
|
280
|
+
elif ptypes.is_numeric_dtype(dtype):
|
|
281
|
+
kind = "numeric"
|
|
282
|
+
elif isinstance(dtype, pd.CategoricalDtype):
|
|
283
|
+
kind = "categorical"
|
|
284
|
+
else:
|
|
285
|
+
kind = "categorical"
|
|
286
|
+
try:
|
|
287
|
+
n_unique = int(non_null.nunique())
|
|
288
|
+
except TypeError: # unhashable values such as lists: only resampling makes sense
|
|
289
|
+
return TextColumn(name, series, unique_ratio=float("nan"))
|
|
290
|
+
if n_unique > 1 and ptypes.infer_dtype(non_null, skipna=True) == "string":
|
|
291
|
+
ratio = n_unique / len(non_null)
|
|
292
|
+
if ratio > text_threshold:
|
|
293
|
+
return TextColumn(name, series, unique_ratio=ratio)
|
|
294
|
+
|
|
295
|
+
if kind == "numeric":
|
|
296
|
+
values = to_float_array(series)
|
|
297
|
+
finite = values[np.isfinite(values)]
|
|
298
|
+
if len(np.unique(finite)) <= 1:
|
|
299
|
+
if len(finite):
|
|
300
|
+
value: Any = finite[0]
|
|
301
|
+
else:
|
|
302
|
+
value = non_null.iloc[0] if len(non_null) else None
|
|
303
|
+
return ConstantColumn(name, series, value=value)
|
|
304
|
+
return NumericColumn(name, series, smooth=smooth)
|
|
305
|
+
|
|
306
|
+
if non_null.nunique() <= 1:
|
|
307
|
+
value = non_null.iloc[0] if len(non_null) else None
|
|
308
|
+
return ConstantColumn(name, series, value=value)
|
|
309
|
+
if kind == "temporal":
|
|
310
|
+
return TemporalColumn(name, series, smooth=smooth)
|
|
311
|
+
return CategoricalColumn(name, series, kind=kind)
|