tabdat-synth 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tabdat_synth/__init__.py +67 -0
- tabdat_synth/core/__init__.py +1 -0
- tabdat_synth/core/evaluation.py +269 -0
- tabdat_synth/core/probability.py +21 -0
- tabdat_synth/core/sampling.py +268 -0
- tabdat_synth/core/statistics.py +91 -0
- tabdat_synth/core/validation.py +418 -0
- tabdat_synth/domain/__init__.py +37 -0
- tabdat_synth/domain/contracts.py +201 -0
- tabdat_synth/interfaces/__init__.py +35 -0
- tabdat_synth/interfaces/bundles.py +425 -0
- tabdat_synth/interfaces/io.py +438 -0
- tabdat_synth/interfaces/models.py +163 -0
- tabdat_synth/interfaces/reports.py +67 -0
- tabdat_synth/version.py +3 -0
- tabdat_synth/workflows/__init__.py +19 -0
- tabdat_synth/workflows/evaluation.py +334 -0
- tabdat_synth/workflows/pipelines.py +367 -0
- tabdat_synth-0.1.0.dist-info/METADATA +172 -0
- tabdat_synth-0.1.0.dist-info/RECORD +23 -0
- tabdat_synth-0.1.0.dist-info/WHEEL +5 -0
- tabdat_synth-0.1.0.dist-info/licenses/LICENSE +159 -0
- tabdat_synth-0.1.0.dist-info/top_level.txt +1 -0
tabdat_synth/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Synthetic data generator package."""
|
|
2
|
+
|
|
3
|
+
from tabdat_synth.version import __version__
|
|
4
|
+
from tabdat_synth.domain.contracts import (
|
|
5
|
+
BernoulliResponseSpec,
|
|
6
|
+
CategoricalModelStepArtifact,
|
|
7
|
+
ColumnSpec,
|
|
8
|
+
CoefficientSumSpec,
|
|
9
|
+
CoefficientTerm,
|
|
10
|
+
ConfiguredStepArtifact,
|
|
11
|
+
EmpiricalStepArtifact,
|
|
12
|
+
DatasetSchema,
|
|
13
|
+
EvaluationReport,
|
|
14
|
+
FittedSynthesizer,
|
|
15
|
+
GenerationSpec,
|
|
16
|
+
GenerationStep,
|
|
17
|
+
MetricResult,
|
|
18
|
+
ModelBackendSpec,
|
|
19
|
+
TruncatedNormalStepArtifact,
|
|
20
|
+
)
|
|
21
|
+
from tabdat_synth.interfaces.bundles import (
|
|
22
|
+
load_fitted_synthesizer,
|
|
23
|
+
save_fitted_synthesizer,
|
|
24
|
+
)
|
|
25
|
+
from tabdat_synth.interfaces.io import load_generation_spec
|
|
26
|
+
from tabdat_synth.interfaces.reports import (
|
|
27
|
+
evaluation_report_to_dict,
|
|
28
|
+
save_evaluation_report,
|
|
29
|
+
)
|
|
30
|
+
from tabdat_synth.workflows.evaluation import (
|
|
31
|
+
evaluate_from_spec,
|
|
32
|
+
evaluate_synthetic_data,
|
|
33
|
+
)
|
|
34
|
+
from tabdat_synth.workflows.pipelines import (
|
|
35
|
+
fit_synthesizer,
|
|
36
|
+
generate_from_fitted,
|
|
37
|
+
generate_from_spec,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"__version__",
|
|
42
|
+
"BernoulliResponseSpec",
|
|
43
|
+
"CategoricalModelStepArtifact",
|
|
44
|
+
"ColumnSpec",
|
|
45
|
+
"CoefficientSumSpec",
|
|
46
|
+
"CoefficientTerm",
|
|
47
|
+
"ConfiguredStepArtifact",
|
|
48
|
+
"DatasetSchema",
|
|
49
|
+
"EmpiricalStepArtifact",
|
|
50
|
+
"EvaluationReport",
|
|
51
|
+
"MetricResult",
|
|
52
|
+
"FittedSynthesizer",
|
|
53
|
+
"GenerationSpec",
|
|
54
|
+
"GenerationStep",
|
|
55
|
+
"ModelBackendSpec",
|
|
56
|
+
"TruncatedNormalStepArtifact",
|
|
57
|
+
"evaluate_from_spec",
|
|
58
|
+
"evaluate_synthetic_data",
|
|
59
|
+
"evaluation_report_to_dict",
|
|
60
|
+
"fit_synthesizer",
|
|
61
|
+
"generate_from_fitted",
|
|
62
|
+
"generate_from_spec",
|
|
63
|
+
"load_fitted_synthesizer",
|
|
64
|
+
"load_generation_spec",
|
|
65
|
+
"save_evaluation_report",
|
|
66
|
+
"save_fitted_synthesizer",
|
|
67
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pure scientific kernels for synthetic data generation."""
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import polars as pl
|
|
7
|
+
from scipy.stats import ks_2samp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def null_rate(series: pl.Series) -> float:
|
|
11
|
+
"""Return the fraction of null rows in a series."""
|
|
12
|
+
if len(series) == 0:
|
|
13
|
+
return 0.0
|
|
14
|
+
return series.null_count() / len(series)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def null_rate_delta(source: pl.Series, synthetic: pl.Series) -> float:
|
|
18
|
+
"""Return synthetic null-rate minus source null-rate."""
|
|
19
|
+
return null_rate(synthetic) - null_rate(source)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def categorical_total_variation_distance(
|
|
23
|
+
source: pl.Series,
|
|
24
|
+
synthetic: pl.Series,
|
|
25
|
+
) -> float:
|
|
26
|
+
"""Compare categorical distributions with total-variation distance."""
|
|
27
|
+
source_values = _string_values(source)
|
|
28
|
+
synthetic_values = _string_values(synthetic)
|
|
29
|
+
if source_values.size == 0 and synthetic_values.size == 0:
|
|
30
|
+
return 0.0
|
|
31
|
+
if source_values.size == 0 or synthetic_values.size == 0:
|
|
32
|
+
return 1.0
|
|
33
|
+
|
|
34
|
+
source_freq = _category_proportions(source_values)
|
|
35
|
+
synthetic_freq = _category_proportions(synthetic_values)
|
|
36
|
+
categories = set(source_freq) | set(synthetic_freq)
|
|
37
|
+
distance = sum(
|
|
38
|
+
abs(synthetic_freq.get(category, 0.0) - source_freq.get(category, 0.0))
|
|
39
|
+
for category in categories
|
|
40
|
+
)
|
|
41
|
+
return 0.5 * distance
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def unseen_synthetic_category_rate(
|
|
45
|
+
source: pl.Series,
|
|
46
|
+
synthetic: pl.Series,
|
|
47
|
+
) -> float:
|
|
48
|
+
"""Return the share of synthetic non-null values unseen in source data."""
|
|
49
|
+
source_values = _string_values(source)
|
|
50
|
+
synthetic_values = _string_values(synthetic)
|
|
51
|
+
if synthetic_values.size == 0:
|
|
52
|
+
return 0.0
|
|
53
|
+
if source_values.size == 0:
|
|
54
|
+
return 1.0
|
|
55
|
+
|
|
56
|
+
observed = set(source_values.tolist())
|
|
57
|
+
unseen_count = sum(value not in observed for value in synthetic_values.tolist())
|
|
58
|
+
return unseen_count / synthetic_values.size
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def numeric_summary_deltas(
|
|
62
|
+
source: pl.Series,
|
|
63
|
+
synthetic: pl.Series,
|
|
64
|
+
) -> dict[str, float | None]:
|
|
65
|
+
"""Return numeric summary statistics and synthetic-minus-source deltas."""
|
|
66
|
+
source_summary = _numeric_summary(_finite_numeric_values(source))
|
|
67
|
+
synthetic_summary = _numeric_summary(_finite_numeric_values(synthetic))
|
|
68
|
+
return {
|
|
69
|
+
"source_mean": source_summary["mean"],
|
|
70
|
+
"synthetic_mean": synthetic_summary["mean"],
|
|
71
|
+
"mean_delta": _optional_delta(
|
|
72
|
+
source_summary["mean"], synthetic_summary["mean"]
|
|
73
|
+
),
|
|
74
|
+
"source_std": source_summary["std"],
|
|
75
|
+
"synthetic_std": synthetic_summary["std"],
|
|
76
|
+
"std_delta": _optional_delta(source_summary["std"], synthetic_summary["std"]),
|
|
77
|
+
"source_min": source_summary["min"],
|
|
78
|
+
"synthetic_min": synthetic_summary["min"],
|
|
79
|
+
"min_delta": _optional_delta(source_summary["min"], synthetic_summary["min"]),
|
|
80
|
+
"source_max": source_summary["max"],
|
|
81
|
+
"synthetic_max": synthetic_summary["max"],
|
|
82
|
+
"max_delta": _optional_delta(source_summary["max"], synthetic_summary["max"]),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def numeric_ks_statistic(source: pl.Series, synthetic: pl.Series) -> float | None:
|
|
87
|
+
"""Return a two-sample Kolmogorov-Smirnov statistic for numeric values."""
|
|
88
|
+
source_values = _finite_numeric_values(source)
|
|
89
|
+
synthetic_values = _finite_numeric_values(synthetic)
|
|
90
|
+
if source_values.size == 0 or synthetic_values.size == 0:
|
|
91
|
+
return None
|
|
92
|
+
return float(ks_2samp(source_values, synthetic_values).statistic)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def exact_row_match_rate(source_df: pl.DataFrame, synthetic_df: pl.DataFrame) -> float:
|
|
96
|
+
"""Return the fraction of synthetic rows exactly present in the source data."""
|
|
97
|
+
if synthetic_df.height == 0:
|
|
98
|
+
return 0.0
|
|
99
|
+
_validate_matching_columns(source_df, synthetic_df)
|
|
100
|
+
if source_df.height == 0 or source_df.width == 0:
|
|
101
|
+
return 0.0
|
|
102
|
+
|
|
103
|
+
source_rows = set(source_df.iter_rows())
|
|
104
|
+
matched = sum(row in source_rows for row in synthetic_df.iter_rows())
|
|
105
|
+
return matched / synthetic_df.height
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def duplicate_row_rate(df: pl.DataFrame) -> float:
|
|
109
|
+
"""Return the fraction of rows beyond the unique-row count."""
|
|
110
|
+
if df.height == 0:
|
|
111
|
+
return 0.0
|
|
112
|
+
return (df.height - df.unique().height) / df.height
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def nearest_neighbor_distance_summary(
|
|
116
|
+
source_df: pl.DataFrame,
|
|
117
|
+
synthetic_df: pl.DataFrame,
|
|
118
|
+
columns: Iterable[str],
|
|
119
|
+
*,
|
|
120
|
+
max_pairwise_rows: int = 1000,
|
|
121
|
+
random_seed: int = 343,
|
|
122
|
+
) -> dict[str, float | int | None]:
|
|
123
|
+
"""Return synthetic-to-source nearest-neighbor distances over numeric columns."""
|
|
124
|
+
if max_pairwise_rows <= 0:
|
|
125
|
+
raise ValueError("max_pairwise_rows must be positive")
|
|
126
|
+
|
|
127
|
+
resolved_columns = tuple(columns)
|
|
128
|
+
source_matrix = _numeric_matrix(source_df, resolved_columns)
|
|
129
|
+
synthetic_matrix = _numeric_matrix(synthetic_df, resolved_columns)
|
|
130
|
+
if (
|
|
131
|
+
not resolved_columns
|
|
132
|
+
or source_matrix.shape[0] == 0
|
|
133
|
+
or synthetic_matrix.shape[0] == 0
|
|
134
|
+
):
|
|
135
|
+
return _empty_nearest_neighbor_summary(
|
|
136
|
+
source_matrix.shape[0],
|
|
137
|
+
synthetic_matrix.shape[0],
|
|
138
|
+
len(resolved_columns),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
rng = np.random.default_rng(random_seed)
|
|
142
|
+
source_sample = _sample_rows(source_matrix, max_pairwise_rows, rng)
|
|
143
|
+
synthetic_sample = _sample_rows(synthetic_matrix, max_pairwise_rows, rng)
|
|
144
|
+
scaled_source, scaled_synthetic = _scale_against_source(
|
|
145
|
+
source_sample,
|
|
146
|
+
synthetic_sample,
|
|
147
|
+
)
|
|
148
|
+
nearest = _nearest_distances(scaled_source, scaled_synthetic)
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
"source_rows_compared": int(source_sample.shape[0]),
|
|
152
|
+
"synthetic_rows_compared": int(synthetic_sample.shape[0]),
|
|
153
|
+
"column_count": len(resolved_columns),
|
|
154
|
+
"min": float(np.min(nearest)),
|
|
155
|
+
"p50": float(np.percentile(nearest, 50)),
|
|
156
|
+
"p95": float(np.percentile(nearest, 95)),
|
|
157
|
+
"mean": float(np.mean(nearest)),
|
|
158
|
+
"max": float(np.max(nearest)),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _string_values(series: pl.Series) -> np.ndarray:
|
|
163
|
+
return series.cast(pl.String, strict=False).drop_nulls().to_numpy()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _category_proportions(values: np.ndarray) -> dict[str, float]:
|
|
167
|
+
unique_values, counts = np.unique(values.astype(str), return_counts=True)
|
|
168
|
+
total = float(values.size)
|
|
169
|
+
return {
|
|
170
|
+
str(value): float(count) / total
|
|
171
|
+
for value, count in zip(unique_values, counts, strict=True)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _finite_numeric_values(series: pl.Series) -> np.ndarray:
|
|
176
|
+
values = series.cast(pl.Float64, strict=False).drop_nulls().to_numpy()
|
|
177
|
+
numeric = np.asarray(values, dtype=float)
|
|
178
|
+
return numeric[np.isfinite(numeric)]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _numeric_summary(values: np.ndarray) -> dict[str, float | None]:
|
|
182
|
+
if values.size == 0:
|
|
183
|
+
return {"mean": None, "std": None, "min": None, "max": None}
|
|
184
|
+
return {
|
|
185
|
+
"mean": float(np.mean(values)),
|
|
186
|
+
"std": float(np.std(values)),
|
|
187
|
+
"min": float(np.min(values)),
|
|
188
|
+
"max": float(np.max(values)),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _optional_delta(
|
|
193
|
+
source_value: float | None,
|
|
194
|
+
synthetic_value: float | None,
|
|
195
|
+
) -> float | None:
|
|
196
|
+
if source_value is None or synthetic_value is None:
|
|
197
|
+
return None
|
|
198
|
+
return synthetic_value - source_value
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _validate_matching_columns(
|
|
202
|
+
source_df: pl.DataFrame,
|
|
203
|
+
synthetic_df: pl.DataFrame,
|
|
204
|
+
) -> None:
|
|
205
|
+
if source_df.columns != synthetic_df.columns:
|
|
206
|
+
raise ValueError("source_df and synthetic_df must have matching columns")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _numeric_matrix(df: pl.DataFrame, columns: tuple[str, ...]) -> np.ndarray:
|
|
210
|
+
if not columns or df.height == 0:
|
|
211
|
+
return np.empty((0, len(columns)), dtype=float)
|
|
212
|
+
numeric_df = df.select(
|
|
213
|
+
pl.col(column).cast(pl.Float64, strict=False).alias(column)
|
|
214
|
+
for column in columns
|
|
215
|
+
)
|
|
216
|
+
matrix = np.asarray(numeric_df.to_numpy(), dtype=float)
|
|
217
|
+
if matrix.size == 0:
|
|
218
|
+
return np.empty((0, len(columns)), dtype=float)
|
|
219
|
+
finite_rows = np.isfinite(matrix).all(axis=1)
|
|
220
|
+
return matrix[finite_rows]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _empty_nearest_neighbor_summary(
|
|
224
|
+
source_rows: int,
|
|
225
|
+
synthetic_rows: int,
|
|
226
|
+
column_count: int,
|
|
227
|
+
) -> dict[str, float | int | None]:
|
|
228
|
+
return {
|
|
229
|
+
"source_rows_compared": source_rows,
|
|
230
|
+
"synthetic_rows_compared": synthetic_rows,
|
|
231
|
+
"column_count": column_count,
|
|
232
|
+
"min": None,
|
|
233
|
+
"p50": None,
|
|
234
|
+
"p95": None,
|
|
235
|
+
"mean": None,
|
|
236
|
+
"max": None,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _sample_rows(
|
|
241
|
+
matrix: np.ndarray,
|
|
242
|
+
max_rows: int,
|
|
243
|
+
rng: np.random.Generator,
|
|
244
|
+
) -> np.ndarray:
|
|
245
|
+
if matrix.shape[0] <= max_rows:
|
|
246
|
+
return matrix
|
|
247
|
+
indices = rng.choice(matrix.shape[0], size=max_rows, replace=False)
|
|
248
|
+
return matrix[np.sort(indices)]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _scale_against_source(
|
|
252
|
+
source_matrix: np.ndarray,
|
|
253
|
+
synthetic_matrix: np.ndarray,
|
|
254
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
255
|
+
center = np.mean(source_matrix, axis=0)
|
|
256
|
+
scale = np.std(source_matrix, axis=0)
|
|
257
|
+
scale = np.where(scale > 0, scale, 1.0)
|
|
258
|
+
return (source_matrix - center) / scale, (synthetic_matrix - center) / scale
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _nearest_distances(
|
|
262
|
+
source_matrix: np.ndarray,
|
|
263
|
+
synthetic_matrix: np.ndarray,
|
|
264
|
+
) -> np.ndarray:
|
|
265
|
+
distances = np.empty(synthetic_matrix.shape[0], dtype=float)
|
|
266
|
+
for index, synthetic_row in enumerate(synthetic_matrix):
|
|
267
|
+
row_distances = np.linalg.norm(source_matrix - synthetic_row, axis=1)
|
|
268
|
+
distances[index] = float(np.min(row_distances))
|
|
269
|
+
return distances
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def sigmoid(x: np.ndarray) -> np.ndarray:
|
|
7
|
+
"""Apply sigmoid after clipping extreme log-odds for numerical stability."""
|
|
8
|
+
return 1 / (1 + np.exp(-np.clip(x, -500, 500)))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def logit(p: float | np.ndarray, epsilon: float = 1e-6):
|
|
12
|
+
"""Safely compute logit values for probabilities."""
|
|
13
|
+
p_clipped = np.clip(p, epsilon, 1 - epsilon)
|
|
14
|
+
logit_p = np.log(p_clipped / (1 - p_clipped))
|
|
15
|
+
return logit_p
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def derive_child_seed(base_seed: int, stream_id: int) -> int:
|
|
19
|
+
"""Derive an independent child seed for a specific RNG stream."""
|
|
20
|
+
ss = np.random.SeedSequence([base_seed, stream_id])
|
|
21
|
+
return int(ss.generate_state(1, dtype=np.uint32)[0])
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import polars as pl
|
|
7
|
+
from scipy.stats import truncnorm
|
|
8
|
+
|
|
9
|
+
from tabdat_synth.core.probability import logit, sigmoid
|
|
10
|
+
from tabdat_synth.core.statistics import compute_category_frequencies
|
|
11
|
+
from tabdat_synth.domain.contracts import FreqShift
|
|
12
|
+
|
|
13
|
+
MissingCategoryHandler = Callable[[str], None]
|
|
14
|
+
MISSING_CATEGORY_LABEL = "__MISSING__"
|
|
15
|
+
_AGE_GROUP_BREAKS = [54, 64, 74, 84]
|
|
16
|
+
_AGE_GROUP_LABELS = ["1", "2", "3", "4", "5"]
|
|
17
|
+
_AGE_GROUP_TO_CONTINUOUS = {"1": 57, "2": 67, "3": 77, "4": 87, "5": 92}
|
|
18
|
+
_UNKNOWN_AGE_GROUP_FALLBACK = 57
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def estimate_empirical_distribution(
|
|
22
|
+
series: pl.Series,
|
|
23
|
+
) -> tuple[list[str | int | float], list[float]]:
|
|
24
|
+
"""Estimate a discrete distribution from a prepared source series."""
|
|
25
|
+
values = series.to_list()
|
|
26
|
+
if not values:
|
|
27
|
+
raise ValueError(f"cannot sample from empty series '{series.name}'")
|
|
28
|
+
|
|
29
|
+
counts: dict[str | int | float, int] = {}
|
|
30
|
+
for value in values:
|
|
31
|
+
if value is None:
|
|
32
|
+
raise ValueError(f"series '{series.name}' contains null values")
|
|
33
|
+
counts[value] = counts.get(value, 0) + 1
|
|
34
|
+
|
|
35
|
+
total = float(len(values))
|
|
36
|
+
return list(counts.keys()), [count / total for count in counts.values()]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sample_empirical_distribution(
|
|
40
|
+
n_samples: int,
|
|
41
|
+
values: list[str | int | float] | tuple[str | int | float, ...],
|
|
42
|
+
probabilities: list[float] | tuple[float, ...],
|
|
43
|
+
seed: int,
|
|
44
|
+
) -> np.ndarray:
|
|
45
|
+
"""Sample from a persisted discrete empirical distribution."""
|
|
46
|
+
if not values:
|
|
47
|
+
raise ValueError("cannot sample from an empty empirical distribution")
|
|
48
|
+
if len(values) != len(probabilities):
|
|
49
|
+
raise ValueError("empirical distribution values/probabilities length mismatch")
|
|
50
|
+
|
|
51
|
+
rng = np.random.default_rng(seed=seed)
|
|
52
|
+
return rng.choice(np.asarray(values, dtype=object), size=n_samples, p=probabilities)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sample_empirical_values(
|
|
56
|
+
n_samples: int, series: pl.Series, seed: int
|
|
57
|
+
) -> np.ndarray:
|
|
58
|
+
"""Sample observed values with replacement from a prepared source series."""
|
|
59
|
+
values, probabilities = estimate_empirical_distribution(series)
|
|
60
|
+
return sample_empirical_distribution(n_samples, values, probabilities, seed)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def summarize_truncated_normal_source(
|
|
64
|
+
series: pl.Series,
|
|
65
|
+
minimum: float | None = None,
|
|
66
|
+
maximum: float | None = None,
|
|
67
|
+
) -> tuple[float, float, float, float]:
|
|
68
|
+
"""Return observed moments and effective bounds for a source series."""
|
|
69
|
+
values = series.cast(pl.Float64).to_numpy()
|
|
70
|
+
if values.size == 0:
|
|
71
|
+
raise ValueError(f"cannot sample from empty series '{series.name}'")
|
|
72
|
+
|
|
73
|
+
observed_mean = float(np.mean(values))
|
|
74
|
+
observed_std = float(np.std(values, ddof=1)) if values.size > 1 else 0.0
|
|
75
|
+
lower = minimum if minimum is not None else float(np.min(values))
|
|
76
|
+
upper = maximum if maximum is not None else float(np.max(values))
|
|
77
|
+
|
|
78
|
+
if lower > upper:
|
|
79
|
+
raise ValueError(f"invalid bounds for '{series.name}': {lower} > {upper}")
|
|
80
|
+
|
|
81
|
+
return observed_mean, observed_std, lower, upper
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def sample_truncated_normal_from_summary(
|
|
85
|
+
n_samples: int,
|
|
86
|
+
observed_mean: float,
|
|
87
|
+
observed_std: float,
|
|
88
|
+
minimum: float,
|
|
89
|
+
maximum: float,
|
|
90
|
+
seed: int,
|
|
91
|
+
mean_shift: float = 0.0,
|
|
92
|
+
round_to_int: bool = False,
|
|
93
|
+
) -> np.ndarray:
|
|
94
|
+
"""Sample bounded numeric values from persisted source moments."""
|
|
95
|
+
shifted_mean = observed_mean + mean_shift
|
|
96
|
+
if observed_std == 0.0 or np.isnan(observed_std):
|
|
97
|
+
sampled = np.full(n_samples, np.clip(shifted_mean, minimum, maximum))
|
|
98
|
+
else:
|
|
99
|
+
a = (minimum - shifted_mean) / observed_std
|
|
100
|
+
b = (maximum - shifted_mean) / observed_std
|
|
101
|
+
sampled = truncnorm.rvs(
|
|
102
|
+
a,
|
|
103
|
+
b,
|
|
104
|
+
loc=shifted_mean,
|
|
105
|
+
scale=observed_std,
|
|
106
|
+
size=n_samples,
|
|
107
|
+
random_state=seed,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if round_to_int:
|
|
111
|
+
sampled = np.rint(sampled).astype(int)
|
|
112
|
+
|
|
113
|
+
return sampled
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def sample_truncated_normal_values(
|
|
117
|
+
n_samples: int,
|
|
118
|
+
series: pl.Series,
|
|
119
|
+
seed: int,
|
|
120
|
+
minimum: float | None = None,
|
|
121
|
+
maximum: float | None = None,
|
|
122
|
+
mean_shift: float = 0.0,
|
|
123
|
+
round_to_int: bool = False,
|
|
124
|
+
) -> np.ndarray:
|
|
125
|
+
"""Sample bounded numeric values using observed moments from a source series."""
|
|
126
|
+
observed_mean, observed_std, lower, upper = summarize_truncated_normal_source(
|
|
127
|
+
series,
|
|
128
|
+
minimum=minimum,
|
|
129
|
+
maximum=maximum,
|
|
130
|
+
)
|
|
131
|
+
return sample_truncated_normal_from_summary(
|
|
132
|
+
n_samples,
|
|
133
|
+
observed_mean,
|
|
134
|
+
observed_std,
|
|
135
|
+
lower,
|
|
136
|
+
upper,
|
|
137
|
+
seed,
|
|
138
|
+
mean_shift=mean_shift,
|
|
139
|
+
round_to_int=round_to_int,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def apply_freq_shifts(
|
|
144
|
+
original_freqs: Mapping[str, float],
|
|
145
|
+
shifts: tuple[FreqShift, ...],
|
|
146
|
+
missing_category_handler: MissingCategoryHandler | None = None,
|
|
147
|
+
) -> dict[str, float]:
|
|
148
|
+
"""Apply category frequency shifts in percentage points."""
|
|
149
|
+
if not shifts:
|
|
150
|
+
return dict(original_freqs)
|
|
151
|
+
|
|
152
|
+
shifted_freqs = dict(original_freqs)
|
|
153
|
+
|
|
154
|
+
for shift in shifts:
|
|
155
|
+
if shift.source not in shifted_freqs:
|
|
156
|
+
if missing_category_handler:
|
|
157
|
+
missing_category_handler(
|
|
158
|
+
f"source category '{shift.source}' not found in distribution"
|
|
159
|
+
)
|
|
160
|
+
continue
|
|
161
|
+
if shift.dest not in shifted_freqs:
|
|
162
|
+
if missing_category_handler:
|
|
163
|
+
missing_category_handler(
|
|
164
|
+
f"destination category '{shift.dest}' not found in distribution"
|
|
165
|
+
)
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
shift_amount = shift.shift_pct / 100.0
|
|
169
|
+
shifted_freqs[shift.source] -= shift_amount
|
|
170
|
+
shifted_freqs[shift.dest] += shift_amount
|
|
171
|
+
|
|
172
|
+
if any(freq < 0 for freq in shifted_freqs.values()):
|
|
173
|
+
negative_cats = [cat for cat, freq in shifted_freqs.items() if freq < 0]
|
|
174
|
+
raise ValueError(f"negative frequencies after shifts: {negative_cats}")
|
|
175
|
+
|
|
176
|
+
total = sum(shifted_freqs.values())
|
|
177
|
+
return {cat: freq / total for cat, freq in shifted_freqs.items()}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def generate_continuous_age(
|
|
181
|
+
n_samples: int, original_df: pl.DataFrame, age_drift: float, seed: int
|
|
182
|
+
) -> pl.DataFrame:
|
|
183
|
+
"""Generate continuous age using a truncated normal distribution with drift."""
|
|
184
|
+
age_stats = original_df.select(
|
|
185
|
+
[
|
|
186
|
+
pl.col("age_cont").mean().alias("mean"),
|
|
187
|
+
pl.col("age_cont").std().alias("std"),
|
|
188
|
+
pl.col("age_cont").min().alias("min"),
|
|
189
|
+
pl.col("age_cont").max().alias("max"),
|
|
190
|
+
]
|
|
191
|
+
).row(0)
|
|
192
|
+
|
|
193
|
+
age_mean, age_std, age_min, age_max = age_stats
|
|
194
|
+
age_mean += age_drift
|
|
195
|
+
|
|
196
|
+
a = (age_min - age_mean) / age_std
|
|
197
|
+
b = (age_max - age_mean) / age_std
|
|
198
|
+
|
|
199
|
+
ages = truncnorm.rvs(
|
|
200
|
+
a, b, loc=age_mean, scale=age_std, size=n_samples, random_state=seed
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return pl.DataFrame({"age_cont": ages.round().astype(int)}).with_columns(
|
|
204
|
+
[
|
|
205
|
+
pl.col("age_cont").cast(pl.Int8),
|
|
206
|
+
pl.col("age_cont")
|
|
207
|
+
.cut(breaks=_AGE_GROUP_BREAKS, labels=_AGE_GROUP_LABELS)
|
|
208
|
+
.alias("agegrp"),
|
|
209
|
+
]
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def generate_categorical_start(
|
|
214
|
+
n_samples: int,
|
|
215
|
+
original_df: pl.DataFrame,
|
|
216
|
+
feature_name: str,
|
|
217
|
+
freq_shifts: tuple[FreqShift, ...],
|
|
218
|
+
seed: int,
|
|
219
|
+
missing_category_handler: MissingCategoryHandler | None = None,
|
|
220
|
+
) -> pl.DataFrame:
|
|
221
|
+
"""Generate a categorical starting feature with optional frequency shifts."""
|
|
222
|
+
rng = np.random.default_rng(seed=seed)
|
|
223
|
+
|
|
224
|
+
original_freqs = compute_category_frequencies(original_df[feature_name])
|
|
225
|
+
shifted_freqs = apply_freq_shifts(
|
|
226
|
+
original_freqs, freq_shifts, missing_category_handler
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
categories = list(shifted_freqs.keys())
|
|
230
|
+
probabilities = list(shifted_freqs.values())
|
|
231
|
+
sampled_values = rng.choice(categories, size=n_samples, p=probabilities)
|
|
232
|
+
|
|
233
|
+
df = pl.DataFrame({feature_name: sampled_values}).with_columns(
|
|
234
|
+
pl.col(feature_name).cast(pl.String).cast(pl.Categorical)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if feature_name == "agegrp":
|
|
238
|
+
df = df.with_columns(
|
|
239
|
+
pl.col("agegrp")
|
|
240
|
+
.map_elements(
|
|
241
|
+
lambda x: _AGE_GROUP_TO_CONTINUOUS.get(x, _UNKNOWN_AGE_GROUP_FALLBACK),
|
|
242
|
+
return_dtype=pl.Int8,
|
|
243
|
+
)
|
|
244
|
+
.alias("age_cont")
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return df
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def generate_responses(
|
|
251
|
+
log_odds: np.ndarray, intercept: float, slope: float, seed: int
|
|
252
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
253
|
+
"""Generate risk probabilities and binary outcomes from log-odds."""
|
|
254
|
+
rng = np.random.default_rng(seed=seed)
|
|
255
|
+
|
|
256
|
+
risk_probs = sigmoid(log_odds)
|
|
257
|
+
mean_risk_proba = risk_probs.mean()
|
|
258
|
+
mean_risk_log_odds = logit(mean_risk_proba)
|
|
259
|
+
|
|
260
|
+
response_log_odds = np.where(
|
|
261
|
+
log_odds >= mean_risk_log_odds,
|
|
262
|
+
intercept + slope * (log_odds - mean_risk_log_odds) + mean_risk_log_odds,
|
|
263
|
+
intercept + log_odds,
|
|
264
|
+
)
|
|
265
|
+
response_probs = sigmoid(response_log_odds)
|
|
266
|
+
outcomes = rng.binomial(n=1, p=response_probs)
|
|
267
|
+
|
|
268
|
+
return risk_probs, outcomes
|