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,91 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any, cast
5
+
6
+ import numpy as np
7
+ import polars as pl
8
+
9
+ from tabdat_synth.domain.contracts import CoefficientSumSpec
10
+
11
+
12
+ def create_one_hot_name(
13
+ feature_name: str,
14
+ value: str,
15
+ reference_value: str = "99",
16
+ ) -> str | None:
17
+ """Create one-hot encoded feature name unless value is the reference."""
18
+ return None if value == reference_value else f"{feature_name}_c_{value}"
19
+
20
+
21
+ def calculate_linear_predictor(
22
+ row: Mapping[str, object],
23
+ coefficients: Mapping[str, float],
24
+ reference_value: str = "99",
25
+ ) -> float:
26
+ """Calculate a row-level linear predictor from one-hot coefficient names."""
27
+ score = coefficients["intercept"]
28
+
29
+ for col_name, col_value in row.items():
30
+ if col_name in ["age_cont"]:
31
+ continue
32
+
33
+ feature_name = create_one_hot_name(col_name, str(col_value), reference_value)
34
+ if feature_name and feature_name in coefficients:
35
+ score += coefficients[feature_name]
36
+
37
+ return score
38
+
39
+
40
+ def calculate_weighted_sum(
41
+ row: Mapping[str, object],
42
+ coefficient_sum: CoefficientSumSpec,
43
+ ) -> float:
44
+ """Calculate a coefficient-based weighted sum for one generated row."""
45
+ score = coefficient_sum.intercept
46
+
47
+ for term in coefficient_sum.terms:
48
+ value = row[term.feature_name]
49
+ if term.value is None:
50
+ score += term.coefficient * float(cast(Any, value))
51
+ continue
52
+
53
+ if str(value) == term.value:
54
+ score += term.coefficient
55
+
56
+ return score
57
+
58
+
59
+ def calculate_weighted_sums(
60
+ df: pl.DataFrame,
61
+ coefficient_sum: CoefficientSumSpec,
62
+ ) -> np.ndarray:
63
+ """Calculate coefficient-based weighted sums for a dataframe."""
64
+ if df.height == 0:
65
+ return np.array([], dtype=float)
66
+ if not coefficient_sum.terms:
67
+ return np.full(df.height, coefficient_sum.intercept, dtype=float)
68
+
69
+ score = pl.lit(coefficient_sum.intercept, dtype=pl.Float64)
70
+ for term in coefficient_sum.terms:
71
+ coefficient = pl.lit(term.coefficient, dtype=pl.Float64)
72
+ if term.value is None:
73
+ term_expr = pl.col(term.feature_name).cast(pl.Float64, strict=False)
74
+ else:
75
+ term_expr = (
76
+ pl.col(term.feature_name)
77
+ .cast(pl.String)
78
+ .eq(pl.lit(term.value))
79
+ .fill_null(False)
80
+ .cast(pl.Float64)
81
+ )
82
+ score += term_expr * coefficient
83
+
84
+ return df.select(score.alias("weighted_sum"))["weighted_sum"].to_numpy()
85
+
86
+
87
+ def compute_category_frequencies(series: pl.Series) -> dict[str, float]:
88
+ """Compute observed category proportions for a Polars series."""
89
+ counts = series.value_counts().sort("count", descending=True)
90
+ total = counts["count"].sum()
91
+ return {str(row[0]): row[1] / total for row in counts.iter_rows()}
@@ -0,0 +1,418 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from collections.abc import Iterable, Mapping
5
+ from typing import Any, cast
6
+
7
+ import polars as pl
8
+
9
+ from tabdat_synth.domain.contracts import (
10
+ BernoulliResponseSpec,
11
+ CoefficientSumSpec,
12
+ ColumnSpec,
13
+ DatasetSchema,
14
+ GenerationSpec,
15
+ GenerationStep,
16
+ ModelBackendSpec,
17
+ )
18
+
19
+ ALLOWED_MISSING_VALUE_POLICIES = {
20
+ "categorical": {"forbid", "as_category"},
21
+ "integer": {"forbid", "mean_impute"},
22
+ "float": {"forbid", "mean_impute"},
23
+ }
24
+ _SOURCE_STEP_KINDS = {"empirical", "truncated_normal", "categorical_model"}
25
+ _FEATURE_STEP_KINDS = {"categorical_model", "coefficient_sum"}
26
+ _INPUT_STEP_KINDS = {"sigmoid_probability", "bernoulli_response"}
27
+
28
+
29
+ def validate_generation_spec(spec: GenerationSpec) -> None:
30
+ """Validate the declarative generation spec."""
31
+ if spec.n_samples <= 0:
32
+ raise ValueError("n_samples must be positive")
33
+
34
+ validate_dataset_schema(spec.schema)
35
+ validate_model_backend_spec(spec.model_backend)
36
+
37
+ schema_columns = spec.schema.columns_by_name
38
+ step_outputs: dict[str, GenerationStep] = {}
39
+
40
+ for step in spec.steps:
41
+ if step.output_column in step_outputs:
42
+ raise ValueError(f"duplicate output column '{step.output_column}' in steps")
43
+ if step.output_column not in schema_columns:
44
+ raise ValueError(f"unknown output column '{step.output_column}'")
45
+
46
+ output_spec = schema_columns[step.output_column]
47
+ if output_spec.role == "identifier":
48
+ raise ValueError(
49
+ f"output column '{step.output_column}' cannot use identifier role"
50
+ )
51
+
52
+ _validate_step_shape(step, schema_columns)
53
+ step_outputs[step.output_column] = step
54
+
55
+ for step in spec.steps:
56
+ for dependency in step_dependencies(step):
57
+ if dependency not in step_outputs:
58
+ raise ValueError(
59
+ f"unknown dependency column '{dependency}' in step '{step.name}'"
60
+ )
61
+ if dependency == step.output_column:
62
+ raise ValueError(f"step '{step.name}' cannot depend on itself")
63
+
64
+ _validate_terminal_bernoulli_steps(spec)
65
+ topologically_sort_steps(spec)
66
+
67
+
68
+ def validate_model_backend_spec(model_backend: ModelBackendSpec) -> None:
69
+ """Validate built-in model-backend configuration."""
70
+ if not model_backend.kind:
71
+ raise ValueError("model_backend.kind is required")
72
+
73
+ params = model_backend.params
74
+ if model_backend.kind == "lightgbm":
75
+ _validate_allowed_param_keys(
76
+ params,
77
+ allowed={"n_estimators", "verbosity"},
78
+ context="lightgbm",
79
+ )
80
+ n_estimators = params.get("n_estimators", 100)
81
+ if int(n_estimators) <= 0:
82
+ raise ValueError("lightgbm model_backend.params.n_estimators must be positive")
83
+ return
84
+
85
+ if model_backend.kind == "logistic_regression":
86
+ _validate_allowed_param_keys(
87
+ params,
88
+ allowed={"C", "max_iter", "solver"},
89
+ context="logistic_regression",
90
+ )
91
+ max_iter = params.get("max_iter", 500)
92
+ if int(max_iter) <= 0:
93
+ raise ValueError(
94
+ "logistic_regression model_backend.params.max_iter must be positive"
95
+ )
96
+ c_value = params.get("C", 1.0)
97
+ if float(c_value) <= 0:
98
+ raise ValueError("logistic_regression model_backend.params.C must be positive")
99
+
100
+
101
+ def _validate_allowed_param_keys(
102
+ params: Mapping[str, object],
103
+ *,
104
+ allowed: set[str],
105
+ context: str,
106
+ ) -> None:
107
+ unexpected = sorted(set(params) - allowed)
108
+ if unexpected:
109
+ raise ValueError(
110
+ f"{context} model_backend.params contains unsupported keys: {unexpected}"
111
+ )
112
+
113
+
114
+ def validate_dataset_schema(schema: DatasetSchema) -> None:
115
+ """Validate static dataset schema declarations."""
116
+ seen: set[str] = set()
117
+ for column in schema.columns:
118
+ if column.name in seen:
119
+ raise ValueError(f"duplicate schema column '{column.name}'")
120
+ seen.add(column.name)
121
+ _validate_column_spec(column)
122
+
123
+
124
+ def _validate_column_spec(column: ColumnSpec) -> None:
125
+ allowed_policies = ALLOWED_MISSING_VALUE_POLICIES[column.data_type]
126
+ if column.missing_value_policy not in allowed_policies:
127
+ raise ValueError(
128
+ f"column '{column.name}' uses invalid missing_value_policy "
129
+ f"'{column.missing_value_policy}' for {column.data_type}"
130
+ )
131
+
132
+ if column.data_type == "categorical":
133
+ if column.minimum is not None or column.maximum is not None:
134
+ raise ValueError(
135
+ f"categorical column '{column.name}' cannot declare numeric bounds"
136
+ )
137
+ if len(set(column.allowed_values)) != len(column.allowed_values):
138
+ raise ValueError(f"column '{column.name}' has duplicate allowed_values")
139
+ else:
140
+ if column.allowed_values:
141
+ raise ValueError(
142
+ f"numeric column '{column.name}' cannot declare allowed_values"
143
+ )
144
+ if (
145
+ column.minimum is not None
146
+ and column.maximum is not None
147
+ and column.minimum > column.maximum
148
+ ):
149
+ raise ValueError(f"column '{column.name}' has minimum greater than maximum")
150
+
151
+
152
+ def _validate_step_shape(
153
+ step: GenerationStep,
154
+ schema_columns: dict[str, ColumnSpec],
155
+ ) -> None:
156
+ output_spec = schema_columns[step.output_column]
157
+
158
+ if step.kind in _SOURCE_STEP_KINDS:
159
+ if step.source_column is None:
160
+ raise ValueError(f"{step.kind} step '{step.name}' requires source_column")
161
+ if step.source_column not in schema_columns:
162
+ raise ValueError(f"unknown source column '{step.source_column}'")
163
+ elif step.source_column is not None:
164
+ raise ValueError(f"{step.kind} step '{step.name}' cannot declare source_column")
165
+
166
+ if step.kind in _INPUT_STEP_KINDS:
167
+ if step.input_column is None:
168
+ raise ValueError(f"{step.kind} step '{step.name}' requires input_column")
169
+ elif step.input_column is not None:
170
+ raise ValueError(f"{step.kind} step '{step.name}' cannot declare input_column")
171
+
172
+ if step.kind == "categorical_model":
173
+ if not step.parent_columns:
174
+ raise ValueError(
175
+ f"categorical_model step '{step.name}' requires parent_columns"
176
+ )
177
+ if output_spec.data_type != "categorical":
178
+ raise ValueError(
179
+ f"categorical_model step '{step.name}' requires categorical output"
180
+ )
181
+ elif step.kind == "coefficient_sum":
182
+ _validate_coefficient_sum_step(step, output_spec)
183
+ elif step.parent_columns:
184
+ raise ValueError(f"{step.kind} step '{step.name}' cannot declare parent_columns")
185
+
186
+ if step.kind == "truncated_normal" and output_spec.data_type == "categorical":
187
+ raise ValueError(
188
+ f"truncated_normal step '{step.name}' cannot target a categorical column"
189
+ )
190
+
191
+ if step.kind == "sigmoid_probability":
192
+ if output_spec.data_type != "float":
193
+ raise ValueError(
194
+ f"sigmoid_probability step '{step.name}' requires float output"
195
+ )
196
+ _validate_forbidden_specs(step, response=False, coefficient_sum=False)
197
+
198
+ if step.kind == "bernoulli_response":
199
+ if output_spec.data_type != "integer":
200
+ raise ValueError(
201
+ f"bernoulli_response step '{step.name}' requires integer output"
202
+ )
203
+ if step.response is None:
204
+ raise ValueError(f"bernoulli_response step '{step.name}' requires response")
205
+ _validate_response_spec(step.name, step.response)
206
+ if step.coefficient_sum is not None:
207
+ raise ValueError(
208
+ f"bernoulli_response step '{step.name}' cannot declare coefficient_sum"
209
+ )
210
+
211
+ if step.kind in {"empirical", "truncated_normal", "categorical_model"}:
212
+ _validate_forbidden_specs(step, response=True, coefficient_sum=True)
213
+
214
+
215
+ def _validate_coefficient_sum_step(
216
+ step: GenerationStep,
217
+ output_spec: ColumnSpec,
218
+ ) -> None:
219
+ if output_spec.data_type != "float":
220
+ raise ValueError(f"coefficient_sum step '{step.name}' requires float output")
221
+ if step.coefficient_sum is None:
222
+ raise ValueError(f"coefficient_sum step '{step.name}' requires coefficient_sum")
223
+ if step.input_column is not None:
224
+ raise ValueError(f"coefficient_sum step '{step.name}' cannot declare input_column")
225
+ if step.response is not None:
226
+ raise ValueError(f"coefficient_sum step '{step.name}' cannot declare response")
227
+
228
+ _validate_coefficient_sum_spec(step.name, step.coefficient_sum, step.parent_columns)
229
+
230
+
231
+ def _validate_coefficient_sum_spec(
232
+ step_name: str,
233
+ coefficient_sum: CoefficientSumSpec,
234
+ parent_columns: tuple[str, ...],
235
+ ) -> None:
236
+ for term in coefficient_sum.terms:
237
+ if term.feature_name not in parent_columns:
238
+ raise ValueError(
239
+ f"coefficient_sum step '{step_name}' term references "
240
+ f"unknown parent feature '{term.feature_name}'"
241
+ )
242
+ if term.value == coefficient_sum.reference_value:
243
+ raise ValueError(
244
+ f"coefficient_sum step '{step_name}' cannot assign a coefficient to "
245
+ f"the reference value '{coefficient_sum.reference_value}'"
246
+ )
247
+
248
+
249
+ def _validate_response_spec(step_name: str, response: BernoulliResponseSpec) -> None:
250
+ if response.slope <= 0:
251
+ raise ValueError(
252
+ f"bernoulli_response step '{step_name}' requires positive response.slope"
253
+ )
254
+
255
+
256
+ def _validate_forbidden_specs(
257
+ step: GenerationStep,
258
+ *,
259
+ response: bool,
260
+ coefficient_sum: bool,
261
+ ) -> None:
262
+ if response and step.response is not None:
263
+ raise ValueError(f"{step.kind} step '{step.name}' cannot declare response")
264
+ if coefficient_sum and step.coefficient_sum is not None:
265
+ raise ValueError(
266
+ f"{step.kind} step '{step.name}' cannot declare coefficient_sum"
267
+ )
268
+
269
+
270
+ def _validate_terminal_bernoulli_steps(spec: GenerationSpec) -> None:
271
+ bernoulli_outputs = {
272
+ step.output_column
273
+ for step in spec.steps
274
+ if step.kind == "bernoulli_response"
275
+ }
276
+ if not bernoulli_outputs:
277
+ return
278
+
279
+ for step in spec.steps:
280
+ for dependency in step_dependencies(step):
281
+ if dependency in bernoulli_outputs:
282
+ raise ValueError(
283
+ "bernoulli_response outputs must be terminal and cannot be used as "
284
+ f"dependencies; step '{step.name}' depends on '{dependency}'"
285
+ )
286
+
287
+
288
+ def step_dependencies(step: GenerationStep) -> tuple[str, ...]:
289
+ """Return the declared DAG dependencies for one step."""
290
+ dependencies = list(step.parent_columns)
291
+ if step.input_column is not None:
292
+ dependencies.append(step.input_column)
293
+ return tuple(dependencies)
294
+
295
+
296
+ def topologically_sort_steps(spec: GenerationSpec) -> list[GenerationStep]:
297
+ """Return generation steps in dependency order."""
298
+ step_by_output = {step.output_column: step for step in spec.steps}
299
+ indegree = {step.output_column: len(step_dependencies(step)) for step in spec.steps}
300
+ children: dict[str, list[str]] = {step.output_column: [] for step in spec.steps}
301
+
302
+ for step in spec.steps:
303
+ for dependency in step_dependencies(step):
304
+ children[dependency].append(step.output_column)
305
+
306
+ ready = deque(
307
+ step.output_column
308
+ for step in spec.steps
309
+ if indegree[step.output_column] == 0
310
+ )
311
+ ordered: list[GenerationStep] = []
312
+
313
+ while ready:
314
+ output_name = ready.popleft()
315
+ ordered.append(step_by_output[output_name])
316
+ for child in children[output_name]:
317
+ indegree[child] -= 1
318
+ if indegree[child] == 0:
319
+ ready.append(child)
320
+
321
+ if len(ordered) != len(spec.steps):
322
+ raise ValueError("generation step graph contains a cycle")
323
+
324
+ return ordered
325
+
326
+
327
+ def resolve_incoming_ancestor_outputs(
328
+ spec: GenerationSpec,
329
+ step: GenerationStep,
330
+ ) -> tuple[str, ...]:
331
+ """Return all ancestor output columns in stable topological order."""
332
+ ordered_steps = topologically_sort_steps(spec)
333
+ step_by_output = {candidate.output_column: candidate for candidate in spec.steps}
334
+
335
+ pending = list(step.parent_columns)
336
+ ancestor_set: set[str] = set()
337
+
338
+ while pending:
339
+ output_name = pending.pop()
340
+ if output_name in ancestor_set:
341
+ continue
342
+ ancestor_set.add(output_name)
343
+ pending.extend(step_dependencies(step_by_output[output_name]))
344
+
345
+ return tuple(
346
+ candidate.output_column
347
+ for candidate in ordered_steps
348
+ if candidate.output_column in ancestor_set
349
+ )
350
+
351
+
352
+ def validate_required_columns(
353
+ df: pl.DataFrame, required_columns: Iterable[str], context: str
354
+ ) -> None:
355
+ """Ensure required columns exist in a dataframe before use."""
356
+ missing = [column for column in required_columns if column not in df.columns]
357
+ if missing:
358
+ raise ValueError(f"{context} is missing required columns: {missing}")
359
+
360
+
361
+ def validate_dataframe_against_schema(
362
+ df: pl.DataFrame,
363
+ schema: DatasetSchema,
364
+ columns: Iterable[str],
365
+ context: str,
366
+ ) -> None:
367
+ """Validate runtime dataframe values against schema constraints."""
368
+ validate_required_columns(df, columns, context)
369
+
370
+ for column_name in columns:
371
+ column_spec = schema.columns_by_name[column_name]
372
+ series = df[column_name]
373
+
374
+ if not column_spec.nullable and series.null_count() > 0:
375
+ raise ValueError(f"{context} column '{column_name}' contains null values")
376
+
377
+ non_null = series.drop_nulls()
378
+ if non_null.is_empty():
379
+ continue
380
+
381
+ if column_spec.data_type == "categorical" and column_spec.allowed_values:
382
+ observed = set(non_null.cast(pl.String).to_list())
383
+ allowed = set(column_spec.allowed_values)
384
+ unexpected = sorted(observed - allowed)
385
+ if unexpected:
386
+ raise ValueError(
387
+ f"{context} column '{column_name}' has values outside allowed_values: "
388
+ f"{unexpected}"
389
+ )
390
+
391
+ if column_spec.data_type in {"integer", "float"}:
392
+ numeric = non_null.cast(pl.Float64)
393
+ observed_min_raw = numeric.min()
394
+ observed_max_raw = numeric.max()
395
+ observed_min = (
396
+ None if observed_min_raw is None else float(cast(Any, observed_min_raw))
397
+ )
398
+ observed_max = (
399
+ None if observed_max_raw is None else float(cast(Any, observed_max_raw))
400
+ )
401
+ if (
402
+ column_spec.minimum is not None
403
+ and observed_min is not None
404
+ and observed_min < column_spec.minimum
405
+ ):
406
+ raise ValueError(
407
+ f"{context} column '{column_name}' falls below minimum "
408
+ f"{column_spec.minimum}"
409
+ )
410
+ if (
411
+ column_spec.maximum is not None
412
+ and observed_max is not None
413
+ and observed_max > column_spec.maximum
414
+ ):
415
+ raise ValueError(
416
+ f"{context} column '{column_name}' exceeds maximum "
417
+ f"{column_spec.maximum}"
418
+ )
@@ -0,0 +1,37 @@
1
+ """Domain contracts for synthetic data generation."""
2
+
3
+ from tabdat_synth.domain.contracts import (
4
+ BernoulliResponseSpec,
5
+ CategoricalModelStepArtifact,
6
+ ColumnSpec,
7
+ CoefficientSumSpec,
8
+ CoefficientTerm,
9
+ ConfiguredStepArtifact,
10
+ DatasetSchema,
11
+ EmpiricalStepArtifact,
12
+ EvaluationReport,
13
+ FittedSynthesizer,
14
+ GenerationSpec,
15
+ GenerationStep,
16
+ MetricResult,
17
+ ModelBackendSpec,
18
+ TruncatedNormalStepArtifact,
19
+ )
20
+
21
+ __all__ = [
22
+ "BernoulliResponseSpec",
23
+ "CategoricalModelStepArtifact",
24
+ "ColumnSpec",
25
+ "CoefficientSumSpec",
26
+ "CoefficientTerm",
27
+ "ConfiguredStepArtifact",
28
+ "DatasetSchema",
29
+ "EmpiricalStepArtifact",
30
+ "EvaluationReport",
31
+ "FittedSynthesizer",
32
+ "GenerationSpec",
33
+ "GenerationStep",
34
+ "MetricResult",
35
+ "ModelBackendSpec",
36
+ "TruncatedNormalStepArtifact",
37
+ ]