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.
@@ -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)