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
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
|
|
7
|
+
from tabdat_synth.core.evaluation import (
|
|
8
|
+
categorical_total_variation_distance,
|
|
9
|
+
duplicate_row_rate,
|
|
10
|
+
exact_row_match_rate,
|
|
11
|
+
nearest_neighbor_distance_summary,
|
|
12
|
+
null_rate,
|
|
13
|
+
null_rate_delta,
|
|
14
|
+
numeric_ks_statistic,
|
|
15
|
+
numeric_summary_deltas,
|
|
16
|
+
unseen_synthetic_category_rate,
|
|
17
|
+
)
|
|
18
|
+
from tabdat_synth.domain.contracts import (
|
|
19
|
+
DatasetSchema,
|
|
20
|
+
EvaluationReport,
|
|
21
|
+
GenerationSpec,
|
|
22
|
+
MetricResult,
|
|
23
|
+
MetricScalar,
|
|
24
|
+
)
|
|
25
|
+
from tabdat_synth.interfaces.io import (
|
|
26
|
+
load_source_dataframe,
|
|
27
|
+
prepare_source_dataframe,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def evaluate_synthetic_data(
|
|
32
|
+
source_df: pl.DataFrame,
|
|
33
|
+
synthetic_df: pl.DataFrame,
|
|
34
|
+
schema: DatasetSchema,
|
|
35
|
+
*,
|
|
36
|
+
identifier_columns: Iterable[str] = (),
|
|
37
|
+
nearest_neighbor_columns: Iterable[str] | None = None,
|
|
38
|
+
max_pairwise_rows: int = 1000,
|
|
39
|
+
random_seed: int = 343,
|
|
40
|
+
) -> EvaluationReport:
|
|
41
|
+
"""Evaluate synthetic data quality and practical disclosure-risk heuristics."""
|
|
42
|
+
if max_pairwise_rows <= 0:
|
|
43
|
+
raise ValueError("max_pairwise_rows must be positive")
|
|
44
|
+
|
|
45
|
+
identifier_set = _identifier_columns(schema, identifier_columns)
|
|
46
|
+
evaluated_columns = _evaluated_columns(source_df, synthetic_df, schema, identifier_set)
|
|
47
|
+
skipped_columns = _skipped_columns(source_df, synthetic_df, schema, identifier_set)
|
|
48
|
+
|
|
49
|
+
quality_metrics = _quality_metrics(
|
|
50
|
+
source_df,
|
|
51
|
+
synthetic_df,
|
|
52
|
+
schema,
|
|
53
|
+
evaluated_columns,
|
|
54
|
+
)
|
|
55
|
+
disclosure_metrics = _disclosure_risk_metrics(
|
|
56
|
+
source_df,
|
|
57
|
+
synthetic_df,
|
|
58
|
+
schema,
|
|
59
|
+
evaluated_columns,
|
|
60
|
+
nearest_neighbor_columns=nearest_neighbor_columns,
|
|
61
|
+
max_pairwise_rows=max_pairwise_rows,
|
|
62
|
+
random_seed=random_seed,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return EvaluationReport(
|
|
66
|
+
source_row_count=source_df.height,
|
|
67
|
+
synthetic_row_count=synthetic_df.height,
|
|
68
|
+
evaluated_columns=evaluated_columns,
|
|
69
|
+
skipped_columns=skipped_columns,
|
|
70
|
+
quality_metrics=tuple(quality_metrics),
|
|
71
|
+
disclosure_risk_metrics=tuple(disclosure_metrics),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def evaluate_from_spec(
|
|
76
|
+
spec: GenerationSpec,
|
|
77
|
+
synthetic_df: pl.DataFrame,
|
|
78
|
+
*,
|
|
79
|
+
source_df: pl.DataFrame | None = None,
|
|
80
|
+
identifier_columns: Iterable[str] = (),
|
|
81
|
+
nearest_neighbor_columns: Iterable[str] | None = None,
|
|
82
|
+
max_pairwise_rows: int = 1000,
|
|
83
|
+
) -> EvaluationReport:
|
|
84
|
+
"""Evaluate generated rows against the source dataset declared by a spec."""
|
|
85
|
+
raw_source_df = (
|
|
86
|
+
source_df
|
|
87
|
+
if source_df is not None
|
|
88
|
+
else load_source_dataframe(
|
|
89
|
+
spec.source_path,
|
|
90
|
+
spec.source_format,
|
|
91
|
+
member=spec.source_member,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
source_columns = tuple(
|
|
95
|
+
column.name for column in spec.schema.columns if column.name in raw_source_df.columns
|
|
96
|
+
)
|
|
97
|
+
prepared_source_df = prepare_source_dataframe(
|
|
98
|
+
raw_source_df,
|
|
99
|
+
spec.schema,
|
|
100
|
+
columns=source_columns,
|
|
101
|
+
)
|
|
102
|
+
return evaluate_synthetic_data(
|
|
103
|
+
prepared_source_df,
|
|
104
|
+
synthetic_df,
|
|
105
|
+
spec.schema,
|
|
106
|
+
identifier_columns=identifier_columns,
|
|
107
|
+
nearest_neighbor_columns=nearest_neighbor_columns,
|
|
108
|
+
max_pairwise_rows=max_pairwise_rows,
|
|
109
|
+
random_seed=spec.random_seed,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _identifier_columns(
|
|
114
|
+
schema: DatasetSchema,
|
|
115
|
+
explicit_identifier_columns: Iterable[str],
|
|
116
|
+
) -> set[str]:
|
|
117
|
+
return {
|
|
118
|
+
column.name for column in schema.columns if column.role == "identifier"
|
|
119
|
+
} | set(explicit_identifier_columns)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _evaluated_columns(
|
|
123
|
+
source_df: pl.DataFrame,
|
|
124
|
+
synthetic_df: pl.DataFrame,
|
|
125
|
+
schema: DatasetSchema,
|
|
126
|
+
identifier_columns: set[str],
|
|
127
|
+
) -> tuple[str, ...]:
|
|
128
|
+
return tuple(
|
|
129
|
+
column.name
|
|
130
|
+
for column in schema.columns
|
|
131
|
+
if column.name not in identifier_columns
|
|
132
|
+
and column.name in source_df.columns
|
|
133
|
+
and column.name in synthetic_df.columns
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _skipped_columns(
|
|
138
|
+
source_df: pl.DataFrame,
|
|
139
|
+
synthetic_df: pl.DataFrame,
|
|
140
|
+
schema: DatasetSchema,
|
|
141
|
+
identifier_columns: set[str],
|
|
142
|
+
) -> tuple[str, ...]:
|
|
143
|
+
return tuple(
|
|
144
|
+
column.name
|
|
145
|
+
for column in schema.columns
|
|
146
|
+
if column.name not in identifier_columns
|
|
147
|
+
and (column.name not in source_df.columns or column.name not in synthetic_df.columns)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _quality_metrics(
|
|
152
|
+
source_df: pl.DataFrame,
|
|
153
|
+
synthetic_df: pl.DataFrame,
|
|
154
|
+
schema: DatasetSchema,
|
|
155
|
+
evaluated_columns: tuple[str, ...],
|
|
156
|
+
) -> list[MetricResult]:
|
|
157
|
+
schema_columns = schema.columns_by_name
|
|
158
|
+
metrics: list[MetricResult] = []
|
|
159
|
+
|
|
160
|
+
for column_name in evaluated_columns:
|
|
161
|
+
column_spec = schema_columns[column_name]
|
|
162
|
+
source_series = source_df[column_name]
|
|
163
|
+
synthetic_series = synthetic_df[column_name]
|
|
164
|
+
metrics.append(
|
|
165
|
+
MetricResult(
|
|
166
|
+
category="quality",
|
|
167
|
+
name="null_rate_delta",
|
|
168
|
+
column=column_name,
|
|
169
|
+
values={
|
|
170
|
+
"source_null_rate": null_rate(source_series),
|
|
171
|
+
"synthetic_null_rate": null_rate(synthetic_series),
|
|
172
|
+
"delta": null_rate_delta(source_series, synthetic_series),
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if column_spec.data_type == "categorical":
|
|
178
|
+
metrics.extend(
|
|
179
|
+
_categorical_quality_metrics(column_name, source_series, synthetic_series)
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
metrics.extend(
|
|
183
|
+
_numeric_quality_metrics(column_name, source_series, synthetic_series)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return metrics
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _categorical_quality_metrics(
|
|
190
|
+
column_name: str,
|
|
191
|
+
source_series: pl.Series,
|
|
192
|
+
synthetic_series: pl.Series,
|
|
193
|
+
) -> list[MetricResult]:
|
|
194
|
+
return [
|
|
195
|
+
MetricResult(
|
|
196
|
+
category="quality",
|
|
197
|
+
name="categorical_total_variation_distance",
|
|
198
|
+
column=column_name,
|
|
199
|
+
values={
|
|
200
|
+
"value": categorical_total_variation_distance(
|
|
201
|
+
source_series,
|
|
202
|
+
synthetic_series,
|
|
203
|
+
)
|
|
204
|
+
},
|
|
205
|
+
),
|
|
206
|
+
MetricResult(
|
|
207
|
+
category="quality",
|
|
208
|
+
name="unseen_synthetic_category_rate",
|
|
209
|
+
column=column_name,
|
|
210
|
+
values={"value": unseen_synthetic_category_rate(source_series, synthetic_series)},
|
|
211
|
+
),
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _numeric_quality_metrics(
|
|
216
|
+
column_name: str,
|
|
217
|
+
source_series: pl.Series,
|
|
218
|
+
synthetic_series: pl.Series,
|
|
219
|
+
) -> list[MetricResult]:
|
|
220
|
+
return [
|
|
221
|
+
MetricResult(
|
|
222
|
+
category="quality",
|
|
223
|
+
name="numeric_summary_delta",
|
|
224
|
+
column=column_name,
|
|
225
|
+
values=numeric_summary_deltas(source_series, synthetic_series),
|
|
226
|
+
),
|
|
227
|
+
MetricResult(
|
|
228
|
+
category="quality",
|
|
229
|
+
name="numeric_ks_statistic",
|
|
230
|
+
column=column_name,
|
|
231
|
+
values={"value": numeric_ks_statistic(source_series, synthetic_series)},
|
|
232
|
+
),
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _disclosure_risk_metrics(
|
|
237
|
+
source_df: pl.DataFrame,
|
|
238
|
+
synthetic_df: pl.DataFrame,
|
|
239
|
+
schema: DatasetSchema,
|
|
240
|
+
evaluated_columns: tuple[str, ...],
|
|
241
|
+
*,
|
|
242
|
+
nearest_neighbor_columns: Iterable[str] | None,
|
|
243
|
+
max_pairwise_rows: int,
|
|
244
|
+
random_seed: int,
|
|
245
|
+
) -> list[MetricResult]:
|
|
246
|
+
metrics: list[MetricResult] = []
|
|
247
|
+
source_risk_df = source_df.select(list(evaluated_columns))
|
|
248
|
+
synthetic_risk_df = synthetic_df.select(list(evaluated_columns))
|
|
249
|
+
|
|
250
|
+
metrics.extend(
|
|
251
|
+
[
|
|
252
|
+
MetricResult(
|
|
253
|
+
category="disclosure_risk",
|
|
254
|
+
name="exact_row_match_rate",
|
|
255
|
+
values={"value": exact_row_match_rate(source_risk_df, synthetic_risk_df)},
|
|
256
|
+
details={"columns": list(evaluated_columns)},
|
|
257
|
+
),
|
|
258
|
+
MetricResult(
|
|
259
|
+
category="disclosure_risk",
|
|
260
|
+
name="source_duplicate_row_rate",
|
|
261
|
+
values={"value": duplicate_row_rate(source_risk_df)},
|
|
262
|
+
details={"columns": list(evaluated_columns)},
|
|
263
|
+
),
|
|
264
|
+
MetricResult(
|
|
265
|
+
category="disclosure_risk",
|
|
266
|
+
name="synthetic_duplicate_row_rate",
|
|
267
|
+
values={"value": duplicate_row_rate(synthetic_risk_df)},
|
|
268
|
+
details={"columns": list(evaluated_columns)},
|
|
269
|
+
),
|
|
270
|
+
]
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
nearest_columns, skipped_nearest_columns = _nearest_neighbor_columns(
|
|
274
|
+
schema,
|
|
275
|
+
evaluated_columns,
|
|
276
|
+
nearest_neighbor_columns,
|
|
277
|
+
)
|
|
278
|
+
nearest_values = nearest_neighbor_distance_summary(
|
|
279
|
+
source_df,
|
|
280
|
+
synthetic_df,
|
|
281
|
+
nearest_columns,
|
|
282
|
+
max_pairwise_rows=max_pairwise_rows,
|
|
283
|
+
random_seed=random_seed,
|
|
284
|
+
)
|
|
285
|
+
metrics.append(
|
|
286
|
+
MetricResult(
|
|
287
|
+
category="disclosure_risk",
|
|
288
|
+
name="nearest_neighbor_distance_summary",
|
|
289
|
+
values=_metric_values(nearest_values),
|
|
290
|
+
details={
|
|
291
|
+
"columns": list(nearest_columns),
|
|
292
|
+
"skipped_columns": list(skipped_nearest_columns),
|
|
293
|
+
"distance": "scaled Euclidean distance from each synthetic row to nearest source row",
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
return metrics
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _nearest_neighbor_columns(
|
|
301
|
+
schema: DatasetSchema,
|
|
302
|
+
evaluated_columns: tuple[str, ...],
|
|
303
|
+
requested_columns: Iterable[str] | None,
|
|
304
|
+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
305
|
+
schema_columns = schema.columns_by_name
|
|
306
|
+
evaluated_set = set(evaluated_columns)
|
|
307
|
+
if requested_columns is None:
|
|
308
|
+
resolved = tuple(
|
|
309
|
+
column_name
|
|
310
|
+
for column_name in evaluated_columns
|
|
311
|
+
if schema_columns[column_name].data_type in {"integer", "float"}
|
|
312
|
+
)
|
|
313
|
+
return resolved, ()
|
|
314
|
+
|
|
315
|
+
resolved_list = []
|
|
316
|
+
skipped_list = []
|
|
317
|
+
for column_name in requested_columns:
|
|
318
|
+
column_spec = schema_columns.get(column_name)
|
|
319
|
+
if (
|
|
320
|
+
column_name in evaluated_set
|
|
321
|
+
and column_spec is not None
|
|
322
|
+
and column_spec.data_type in {"integer", "float"}
|
|
323
|
+
):
|
|
324
|
+
resolved_list.append(column_name)
|
|
325
|
+
else:
|
|
326
|
+
skipped_list.append(column_name)
|
|
327
|
+
|
|
328
|
+
return tuple(resolved_list), tuple(skipped_list)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _metric_values(
|
|
332
|
+
values: dict[str, float | int | None],
|
|
333
|
+
) -> dict[str, MetricScalar]:
|
|
334
|
+
return {key: value for key, value in values.items()}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import polars as pl
|
|
5
|
+
|
|
6
|
+
from tabdat_synth.core.probability import sigmoid
|
|
7
|
+
from tabdat_synth.core.sampling import (
|
|
8
|
+
estimate_empirical_distribution,
|
|
9
|
+
generate_responses,
|
|
10
|
+
sample_empirical_distribution,
|
|
11
|
+
sample_truncated_normal_from_summary,
|
|
12
|
+
summarize_truncated_normal_source,
|
|
13
|
+
)
|
|
14
|
+
from tabdat_synth.core.statistics import calculate_weighted_sums
|
|
15
|
+
from tabdat_synth.core.validation import (
|
|
16
|
+
resolve_incoming_ancestor_outputs,
|
|
17
|
+
topologically_sort_steps,
|
|
18
|
+
validate_dataframe_against_schema,
|
|
19
|
+
validate_generation_spec,
|
|
20
|
+
validate_required_columns,
|
|
21
|
+
)
|
|
22
|
+
from tabdat_synth.domain.contracts import (
|
|
23
|
+
CategoricalModelStepArtifact,
|
|
24
|
+
ConfiguredStepArtifact,
|
|
25
|
+
EmpiricalStepArtifact,
|
|
26
|
+
FittedSynthesizer,
|
|
27
|
+
FittedStepArtifact,
|
|
28
|
+
GenerationSpec,
|
|
29
|
+
GenerationStep,
|
|
30
|
+
TruncatedNormalStepArtifact,
|
|
31
|
+
)
|
|
32
|
+
from tabdat_synth.interfaces.io import (
|
|
33
|
+
load_source_dataframe,
|
|
34
|
+
prepare_source_dataframe,
|
|
35
|
+
)
|
|
36
|
+
from tabdat_synth.interfaces.models import (
|
|
37
|
+
fit_categorical_model,
|
|
38
|
+
sample_categorical_feature,
|
|
39
|
+
)
|
|
40
|
+
from tabdat_synth.version import __version__
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def generate_from_spec(spec: GenerationSpec) -> pl.DataFrame:
|
|
44
|
+
"""Generate synthetic rows from a generation spec."""
|
|
45
|
+
fitted = fit_synthesizer(spec)
|
|
46
|
+
return generate_from_fitted(fitted)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fit_synthesizer(spec: GenerationSpec) -> FittedSynthesizer:
|
|
50
|
+
"""Fit a reusable synthesizer bundle from a generation spec."""
|
|
51
|
+
validate_generation_spec(spec)
|
|
52
|
+
ordered_steps = topologically_sort_steps(spec)
|
|
53
|
+
source_columns = tuple(
|
|
54
|
+
dict.fromkeys(
|
|
55
|
+
step.source_column for step in ordered_steps if step.source_column is not None
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
source_df = load_source_dataframe(
|
|
60
|
+
spec.source_path,
|
|
61
|
+
spec.source_format,
|
|
62
|
+
member=spec.source_member,
|
|
63
|
+
)
|
|
64
|
+
validate_required_columns(source_df, source_columns, "source dataframe")
|
|
65
|
+
|
|
66
|
+
if source_columns:
|
|
67
|
+
prepared_source_df = prepare_source_dataframe(
|
|
68
|
+
source_df,
|
|
69
|
+
spec.schema,
|
|
70
|
+
columns=source_columns,
|
|
71
|
+
)
|
|
72
|
+
validate_dataframe_against_schema(
|
|
73
|
+
prepared_source_df,
|
|
74
|
+
spec.schema,
|
|
75
|
+
source_columns,
|
|
76
|
+
"prepared source dataframe",
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
prepared_source_df = pl.DataFrame()
|
|
80
|
+
|
|
81
|
+
source_row_count = source_df.height
|
|
82
|
+
source_generated_df = pl.DataFrame()
|
|
83
|
+
step_artifacts: list[FittedStepArtifact] = []
|
|
84
|
+
|
|
85
|
+
for step in ordered_steps:
|
|
86
|
+
artifact, source_output = _fit_generation_step(
|
|
87
|
+
step,
|
|
88
|
+
prepared_source_df,
|
|
89
|
+
source_generated_df,
|
|
90
|
+
source_row_count,
|
|
91
|
+
spec,
|
|
92
|
+
)
|
|
93
|
+
step_artifacts.append(artifact)
|
|
94
|
+
if source_output is not None:
|
|
95
|
+
source_generated_df = _append_output_series(source_generated_df, source_output)
|
|
96
|
+
|
|
97
|
+
return FittedSynthesizer(
|
|
98
|
+
spec=spec,
|
|
99
|
+
step_artifacts=tuple(step_artifacts),
|
|
100
|
+
package_version=__version__,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def generate_from_fitted(
|
|
105
|
+
fitted: FittedSynthesizer,
|
|
106
|
+
*,
|
|
107
|
+
n_samples: int | None = None,
|
|
108
|
+
random_seed: int | None = None,
|
|
109
|
+
) -> pl.DataFrame:
|
|
110
|
+
"""Generate synthetic rows from a fitted synthesizer bundle."""
|
|
111
|
+
validate_generation_spec(fitted.spec)
|
|
112
|
+
_validate_fitted_synthesizer(fitted)
|
|
113
|
+
|
|
114
|
+
resolved_n_samples = fitted.spec.n_samples if n_samples is None else n_samples
|
|
115
|
+
resolved_seed = fitted.spec.random_seed if random_seed is None else random_seed
|
|
116
|
+
if resolved_n_samples <= 0:
|
|
117
|
+
raise ValueError("n_samples must be positive")
|
|
118
|
+
|
|
119
|
+
synth_df = pl.DataFrame()
|
|
120
|
+
|
|
121
|
+
for step_idx, artifact in enumerate(fitted.step_artifacts):
|
|
122
|
+
output_series = _execute_fitted_step(
|
|
123
|
+
artifact,
|
|
124
|
+
synth_df,
|
|
125
|
+
step_idx,
|
|
126
|
+
fitted.spec,
|
|
127
|
+
resolved_n_samples,
|
|
128
|
+
resolved_seed,
|
|
129
|
+
)
|
|
130
|
+
synth_df = _append_output_series(synth_df, output_series)
|
|
131
|
+
|
|
132
|
+
generated_columns = tuple(
|
|
133
|
+
artifact.step.output_column for artifact in fitted.step_artifacts
|
|
134
|
+
)
|
|
135
|
+
validate_dataframe_against_schema(
|
|
136
|
+
synth_df,
|
|
137
|
+
fitted.spec.schema,
|
|
138
|
+
generated_columns,
|
|
139
|
+
"generated dataframe",
|
|
140
|
+
)
|
|
141
|
+
return synth_df
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _fit_generation_step(
|
|
145
|
+
step: GenerationStep,
|
|
146
|
+
prepared_source_df: pl.DataFrame,
|
|
147
|
+
source_generated_df: pl.DataFrame,
|
|
148
|
+
source_row_count: int,
|
|
149
|
+
spec: GenerationSpec,
|
|
150
|
+
) -> tuple[FittedStepArtifact, pl.Series | None]:
|
|
151
|
+
column_spec = spec.schema.columns_by_name[step.output_column]
|
|
152
|
+
|
|
153
|
+
if step.kind == "empirical":
|
|
154
|
+
assert step.source_column is not None
|
|
155
|
+
source_series = prepared_source_df[step.source_column]
|
|
156
|
+
values, probabilities = estimate_empirical_distribution(source_series)
|
|
157
|
+
return (
|
|
158
|
+
EmpiricalStepArtifact(
|
|
159
|
+
step=step,
|
|
160
|
+
values=tuple(values),
|
|
161
|
+
probabilities=tuple(probabilities),
|
|
162
|
+
),
|
|
163
|
+
_cast_output_series(step.output_column, source_series.to_list(), column_spec),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if step.kind == "truncated_normal":
|
|
167
|
+
assert step.source_column is not None
|
|
168
|
+
source_series = prepared_source_df[step.source_column]
|
|
169
|
+
observed_mean, observed_std, minimum, maximum = summarize_truncated_normal_source(
|
|
170
|
+
source_series,
|
|
171
|
+
minimum=column_spec.minimum,
|
|
172
|
+
maximum=column_spec.maximum,
|
|
173
|
+
)
|
|
174
|
+
return (
|
|
175
|
+
TruncatedNormalStepArtifact(
|
|
176
|
+
step=step,
|
|
177
|
+
observed_mean=observed_mean,
|
|
178
|
+
observed_std=observed_std,
|
|
179
|
+
minimum=minimum,
|
|
180
|
+
maximum=maximum,
|
|
181
|
+
round_to_int=column_spec.data_type == "integer",
|
|
182
|
+
),
|
|
183
|
+
_cast_output_series(step.output_column, source_series.to_list(), column_spec),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if step.kind == "categorical_model":
|
|
187
|
+
assert step.source_column is not None
|
|
188
|
+
source_series = prepared_source_df[step.source_column]
|
|
189
|
+
input_columns = resolve_incoming_ancestor_outputs(spec, step)
|
|
190
|
+
X_source = source_generated_df.select(list(input_columns))
|
|
191
|
+
model = fit_categorical_model(
|
|
192
|
+
X_source,
|
|
193
|
+
source_series,
|
|
194
|
+
spec.model_backend,
|
|
195
|
+
spec.random_seed,
|
|
196
|
+
)
|
|
197
|
+
feature_order = tuple(getattr(model, "_sdg_feature_order"))
|
|
198
|
+
return (
|
|
199
|
+
CategoricalModelStepArtifact(
|
|
200
|
+
step=step,
|
|
201
|
+
backend_kind=spec.model_backend.kind,
|
|
202
|
+
input_columns=input_columns,
|
|
203
|
+
feature_order=feature_order,
|
|
204
|
+
learned_categories=tuple(str(value) for value in model.classes_),
|
|
205
|
+
fitted_model=model,
|
|
206
|
+
),
|
|
207
|
+
_cast_output_series(step.output_column, source_series.to_list(), column_spec),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if step.kind == "coefficient_sum":
|
|
211
|
+
assert step.coefficient_sum is not None
|
|
212
|
+
coefficient_values = _calculate_coefficient_sum_series(
|
|
213
|
+
step,
|
|
214
|
+
source_generated_df,
|
|
215
|
+
source_row_count,
|
|
216
|
+
)
|
|
217
|
+
return ConfiguredStepArtifact(step=step), _cast_output_series(
|
|
218
|
+
step.output_column,
|
|
219
|
+
coefficient_values,
|
|
220
|
+
column_spec,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if step.kind == "sigmoid_probability":
|
|
224
|
+
probability_values = _calculate_sigmoid_probability_series(
|
|
225
|
+
step,
|
|
226
|
+
source_generated_df,
|
|
227
|
+
)
|
|
228
|
+
return ConfiguredStepArtifact(step=step), _cast_output_series(
|
|
229
|
+
step.output_column,
|
|
230
|
+
probability_values,
|
|
231
|
+
column_spec,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if step.kind == "bernoulli_response":
|
|
235
|
+
assert step.response is not None
|
|
236
|
+
return ConfiguredStepArtifact(step=step), None
|
|
237
|
+
|
|
238
|
+
raise ValueError(f"unsupported step kind '{step.kind}'")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _execute_fitted_step(
|
|
242
|
+
artifact: FittedStepArtifact,
|
|
243
|
+
synth_df: pl.DataFrame,
|
|
244
|
+
step_idx: int,
|
|
245
|
+
spec: GenerationSpec,
|
|
246
|
+
n_samples: int,
|
|
247
|
+
random_seed: int,
|
|
248
|
+
) -> pl.Series:
|
|
249
|
+
step = artifact.step
|
|
250
|
+
column_spec = spec.schema.columns_by_name[step.output_column]
|
|
251
|
+
step_seed = random_seed + step_idx
|
|
252
|
+
|
|
253
|
+
if isinstance(artifact, EmpiricalStepArtifact):
|
|
254
|
+
sampled_values = sample_empirical_distribution(
|
|
255
|
+
n_samples,
|
|
256
|
+
artifact.values,
|
|
257
|
+
artifact.probabilities,
|
|
258
|
+
step_seed,
|
|
259
|
+
)
|
|
260
|
+
return _cast_output_series(step.output_column, sampled_values, column_spec)
|
|
261
|
+
|
|
262
|
+
if isinstance(artifact, TruncatedNormalStepArtifact):
|
|
263
|
+
sampled_values = sample_truncated_normal_from_summary(
|
|
264
|
+
n_samples,
|
|
265
|
+
artifact.observed_mean,
|
|
266
|
+
artifact.observed_std,
|
|
267
|
+
artifact.minimum,
|
|
268
|
+
artifact.maximum,
|
|
269
|
+
step_seed,
|
|
270
|
+
mean_shift=step.mean_shift,
|
|
271
|
+
round_to_int=artifact.round_to_int,
|
|
272
|
+
)
|
|
273
|
+
return _cast_output_series(step.output_column, sampled_values, column_spec)
|
|
274
|
+
|
|
275
|
+
if isinstance(artifact, CategoricalModelStepArtifact):
|
|
276
|
+
X_synth = synth_df.select(list(artifact.input_columns))
|
|
277
|
+
sampled_categories = sample_categorical_feature(
|
|
278
|
+
artifact.fitted_model,
|
|
279
|
+
X_synth,
|
|
280
|
+
list(artifact.learned_categories),
|
|
281
|
+
step_idx,
|
|
282
|
+
random_seed,
|
|
283
|
+
feature_order=artifact.feature_order,
|
|
284
|
+
)
|
|
285
|
+
return _cast_output_series(step.output_column, sampled_categories, column_spec)
|
|
286
|
+
|
|
287
|
+
if step.kind == "coefficient_sum":
|
|
288
|
+
assert step.coefficient_sum is not None
|
|
289
|
+
values = _calculate_coefficient_sum_series(step, synth_df, n_samples)
|
|
290
|
+
return _cast_output_series(step.output_column, values, column_spec)
|
|
291
|
+
|
|
292
|
+
if step.kind == "sigmoid_probability":
|
|
293
|
+
values = _calculate_sigmoid_probability_series(step, synth_df)
|
|
294
|
+
return _cast_output_series(step.output_column, values, column_spec)
|
|
295
|
+
|
|
296
|
+
if step.kind == "bernoulli_response":
|
|
297
|
+
assert step.response is not None
|
|
298
|
+
input_values = _input_values(step, synth_df)
|
|
299
|
+
_, outcomes = generate_responses(
|
|
300
|
+
input_values,
|
|
301
|
+
step.response.intercept,
|
|
302
|
+
step.response.slope,
|
|
303
|
+
step_seed,
|
|
304
|
+
)
|
|
305
|
+
return _cast_output_series(step.output_column, outcomes, column_spec)
|
|
306
|
+
|
|
307
|
+
raise ValueError(f"unsupported step kind '{step.kind}'")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _calculate_coefficient_sum_series(
|
|
311
|
+
step: GenerationStep,
|
|
312
|
+
frame: pl.DataFrame,
|
|
313
|
+
row_count: int,
|
|
314
|
+
) -> np.ndarray:
|
|
315
|
+
if not step.parent_columns:
|
|
316
|
+
assert step.coefficient_sum is not None
|
|
317
|
+
return np.full(row_count, step.coefficient_sum.intercept, dtype=float)
|
|
318
|
+
assert step.coefficient_sum is not None
|
|
319
|
+
return calculate_weighted_sums(
|
|
320
|
+
frame.select(list(step.parent_columns)),
|
|
321
|
+
step.coefficient_sum,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _calculate_sigmoid_probability_series(
|
|
326
|
+
step: GenerationStep,
|
|
327
|
+
frame: pl.DataFrame,
|
|
328
|
+
) -> np.ndarray:
|
|
329
|
+
return sigmoid(_input_values(step, frame))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _input_values(step: GenerationStep, frame: pl.DataFrame) -> np.ndarray:
|
|
333
|
+
assert step.input_column is not None
|
|
334
|
+
return frame[step.input_column].cast(pl.Float64).to_numpy()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _append_output_series(df: pl.DataFrame, series: pl.Series) -> pl.DataFrame:
|
|
338
|
+
if df.width == 0:
|
|
339
|
+
return pl.DataFrame({series.name: series})
|
|
340
|
+
return df.with_columns(series)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _validate_fitted_synthesizer(fitted: FittedSynthesizer) -> None:
|
|
344
|
+
ordered_steps = topologically_sort_steps(fitted.spec)
|
|
345
|
+
if len(ordered_steps) != len(fitted.step_artifacts):
|
|
346
|
+
raise ValueError(
|
|
347
|
+
"fitted synthesizer step count does not match the validated generation graph"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
for expected_step, artifact in zip(ordered_steps, fitted.step_artifacts, strict=True):
|
|
351
|
+
if artifact.step != expected_step:
|
|
352
|
+
raise ValueError(
|
|
353
|
+
"fitted synthesizer step order does not match the validated generation graph"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _cast_output_series(
|
|
358
|
+
output_name: str,
|
|
359
|
+
values: np.ndarray | list[str] | list[int] | list[float],
|
|
360
|
+
column_spec,
|
|
361
|
+
) -> pl.Series:
|
|
362
|
+
output_series = pl.Series(name=output_name, values=values)
|
|
363
|
+
if column_spec.data_type == "categorical":
|
|
364
|
+
return output_series.cast(pl.String).cast(pl.Categorical)
|
|
365
|
+
if column_spec.data_type == "integer":
|
|
366
|
+
return output_series.cast(pl.Int64)
|
|
367
|
+
return output_series.cast(pl.Float64)
|