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