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,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
|
+
]
|