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,438 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import os
5
+ from pathlib import Path
6
+ import zipfile
7
+ from typing import Any, cast
8
+
9
+ import polars as pl
10
+ import yaml # type: ignore[import-untyped]
11
+
12
+ from tabdat_synth.core.sampling import MISSING_CATEGORY_LABEL
13
+ from tabdat_synth.domain.contracts import (
14
+ BernoulliResponseSpec,
15
+ CoefficientSumSpec,
16
+ CoefficientTerm,
17
+ ColumnSpec,
18
+ DatasetSchema,
19
+ GenerationSpec,
20
+ GenerationStep,
21
+ ModelBackendSpec,
22
+ )
23
+
24
+ _LEGACY_RESPONSE_COLUMNS = ("Hosp_1y", "Mort_1y")
25
+ _LEGACY_RISK_COLUMNS = ("pHosp_1y", "pMort_1y")
26
+ _LEGACY_AGE_COLUMNS = ("age_cont", "agegrp")
27
+ _LEGACY_VA_DROP_COLUMNS = ("patienticn", "yr", "Death1y", "intercept")
28
+ _SOURCE_REQUIRED_STEP_KINDS = {"empirical", "truncated_normal", "categorical_model"}
29
+
30
+
31
+ def resolve_va_data_dir(env_key: str = "VA_DIR", load_env: bool = True) -> Path:
32
+ """Resolve the configured VA data directory at the IO boundary."""
33
+ if load_env:
34
+ _load_dotenv_if_available()
35
+
36
+ value = os.getenv(env_key)
37
+ if not value:
38
+ raise ValueError(f"{env_key} is not configured")
39
+
40
+ return Path(value)
41
+
42
+
43
+ def _load_dotenv_if_available() -> None:
44
+ try:
45
+ from dotenv import load_dotenv
46
+ except ModuleNotFoundError as exc:
47
+ if exc.name == "dotenv":
48
+ return
49
+ raise
50
+
51
+ load_dotenv()
52
+
53
+
54
+ def load_and_prepare_data(
55
+ data_path: Path, frac: float, seed: int
56
+ ) -> tuple[pl.DataFrame, list[str]]:
57
+ """Load and prepare the legacy VA parquet dataset for generation."""
58
+ df = (
59
+ pl.read_parquet(data_path)
60
+ .drop(*_LEGACY_VA_DROP_COLUMNS)
61
+ .sample(fraction=frac, seed=seed)
62
+ )
63
+
64
+ target_cols = [
65
+ col
66
+ for col in df.schema.names()
67
+ if col
68
+ not in (
69
+ list(_LEGACY_RESPONSE_COLUMNS)
70
+ + list(_LEGACY_RISK_COLUMNS)
71
+ + list(_LEGACY_AGE_COLUMNS)
72
+ )
73
+ ]
74
+
75
+ expr_list = [
76
+ pl.col(col).cast(pl.String).cast(pl.Categorical)
77
+ for col in target_cols + ["agegrp"]
78
+ ]
79
+ df_prepared = df.with_columns(expr_list)
80
+
81
+ return df_prepared, target_cols
82
+
83
+
84
+ def load_generation_spec(path: Path | str) -> GenerationSpec:
85
+ """Load a YAML generation spec into typed generation contracts."""
86
+ config_path = Path(path).resolve()
87
+ raw = yaml.safe_load(config_path.read_text())
88
+ return load_generation_spec_mapping(raw, base_path=config_path.parent)
89
+
90
+
91
+ def load_generation_spec_mapping(
92
+ raw: object,
93
+ *,
94
+ base_path: Path | None = None,
95
+ ) -> GenerationSpec:
96
+ """Load a raw generation-spec mapping into typed contracts."""
97
+ if not isinstance(raw, dict):
98
+ raise ValueError("generation spec must be a mapping")
99
+ if "va_outcomes" in raw:
100
+ raise ValueError(
101
+ "generation spec 'va_outcomes' is no longer supported; "
102
+ "use coefficient_sum, sigmoid_probability, and bernoulli_response steps"
103
+ )
104
+
105
+ source = raw.get("source", {})
106
+ if not isinstance(source, dict):
107
+ raise ValueError("generation spec 'source' must be a mapping")
108
+
109
+ raw_source_path = source.get("path")
110
+ if not raw_source_path:
111
+ raise ValueError("generation spec source.path is required")
112
+
113
+ schema = _load_dataset_schema(raw.get("schema"))
114
+ steps = _load_generation_steps(raw.get("steps", []))
115
+ model_backend = _load_model_backend(raw.get("model_backend"), raw)
116
+ resolved_source_path = _resolve_source_path(raw_source_path, base_path)
117
+
118
+ return GenerationSpec(
119
+ name=raw["name"],
120
+ source_path=resolved_source_path,
121
+ source_format=source["format"],
122
+ source_member=source.get("member"),
123
+ schema=schema,
124
+ steps=steps,
125
+ model_backend=model_backend,
126
+ n_samples=int(raw.get("n_samples", 100_000)),
127
+ random_seed=int(raw.get("random_seed", 343)),
128
+ )
129
+
130
+
131
+ def generation_spec_to_dict(spec: GenerationSpec) -> dict[str, object]:
132
+ """Convert a typed generation spec into a normalized mapping."""
133
+ source: dict[str, object] = {
134
+ "path": str(spec.source_path),
135
+ "format": spec.source_format,
136
+ }
137
+ if spec.source_member is not None:
138
+ source["member"] = spec.source_member
139
+
140
+ return {
141
+ "name": spec.name,
142
+ "source": source,
143
+ "n_samples": spec.n_samples,
144
+ "random_seed": spec.random_seed,
145
+ "model_backend": {
146
+ "kind": spec.model_backend.kind,
147
+ "params": dict(spec.model_backend.params),
148
+ },
149
+ "schema": {
150
+ "columns": [_column_spec_to_dict(column) for column in spec.schema.columns]
151
+ },
152
+ "steps": [_generation_step_to_dict(step) for step in spec.steps],
153
+ }
154
+
155
+
156
+ def load_source_dataframe(
157
+ path: Path, source_format: str, member: str | None = None
158
+ ) -> pl.DataFrame:
159
+ """Load a generic source dataframe from parquet, csv, or zip-wrapped csv."""
160
+ if source_format == "parquet":
161
+ return pl.read_parquet(path)
162
+ if source_format == "csv":
163
+ return pl.read_csv(path, null_values="")
164
+ if source_format == "zip_csv":
165
+ with zipfile.ZipFile(path) as archive:
166
+ member_name = member or _infer_zip_member(archive, path)
167
+ with archive.open(member_name) as zipped_file:
168
+ return pl.read_csv(io.BytesIO(zipped_file.read()), null_values="")
169
+
170
+ raise ValueError(f"unsupported source_format '{source_format}'")
171
+
172
+
173
+ def prepare_source_dataframe(
174
+ df: pl.DataFrame,
175
+ schema: DatasetSchema,
176
+ columns: tuple[str, ...] | None = None,
177
+ ) -> pl.DataFrame:
178
+ """Apply schema-driven casts and missing-value policies to source data."""
179
+ required_columns = columns or tuple(schema.columns_by_name)
180
+ prepared = df.select(list(required_columns))
181
+
182
+ for column_name in required_columns:
183
+ column_spec = schema.columns_by_name[column_name]
184
+ prepared = prepared.with_columns(_prepare_column(prepared, column_spec))
185
+
186
+ return prepared
187
+
188
+
189
+ def _prepare_column(df: pl.DataFrame, column_spec: ColumnSpec) -> pl.Expr:
190
+ expr = pl.col(column_spec.name)
191
+
192
+ if column_spec.data_type == "categorical":
193
+ expr = expr.cast(pl.String, strict=False)
194
+ if column_spec.missing_value_policy == "as_category":
195
+ expr = expr.fill_null(MISSING_CATEGORY_LABEL)
196
+ return expr.cast(pl.Categorical)
197
+
198
+ if column_spec.data_type == "integer":
199
+ expr = expr.cast(pl.Int64, strict=False)
200
+ if column_spec.missing_value_policy == "mean_impute":
201
+ integer_fill_value = _integer_mean_fill(df[column_spec.name])
202
+ expr = expr.fill_null(integer_fill_value)
203
+ return expr.cast(pl.Int64)
204
+
205
+ expr = expr.cast(pl.Float64, strict=False)
206
+ if column_spec.missing_value_policy == "mean_impute":
207
+ float_fill_value: float = _float_mean_fill(df[column_spec.name])
208
+ expr = expr.fill_null(float_fill_value)
209
+ return expr.cast(pl.Float64)
210
+
211
+
212
+ def _integer_mean_fill(series: pl.Series) -> int:
213
+ mean_value: object = series.cast(pl.Float64, strict=False).mean()
214
+ if mean_value is None:
215
+ return 0
216
+ return int(round(float(cast(Any, mean_value))))
217
+
218
+
219
+ def _float_mean_fill(series: pl.Series) -> float:
220
+ mean_value: object = series.cast(pl.Float64, strict=False).mean()
221
+ if mean_value is None:
222
+ return 0.0
223
+ return float(cast(Any, mean_value))
224
+
225
+
226
+ def _infer_zip_member(archive: zipfile.ZipFile, path: Path) -> str:
227
+ members = [name for name in archive.namelist() if not name.endswith("/")]
228
+ if len(members) != 1:
229
+ raise ValueError(f"zip_csv source '{path}' requires an explicit member")
230
+ return members[0]
231
+
232
+
233
+ def _resolve_source_path(raw_source_path: str, base_path: Path | None) -> Path:
234
+ source_path = Path(raw_source_path)
235
+ if source_path.is_absolute():
236
+ return source_path
237
+ if base_path is not None:
238
+ return (base_path / source_path).resolve()
239
+ return source_path.resolve()
240
+
241
+
242
+ def _load_dataset_schema(raw_schema: object) -> DatasetSchema:
243
+ if not isinstance(raw_schema, dict):
244
+ raise ValueError("generation spec schema must be a mapping")
245
+
246
+ raw_columns = raw_schema.get("columns", [])
247
+ if not isinstance(raw_columns, list):
248
+ raise ValueError("generation spec schema.columns must be a list")
249
+
250
+ columns = []
251
+ for raw_column in raw_columns:
252
+ if not isinstance(raw_column, dict):
253
+ raise ValueError("schema column definitions must be mappings")
254
+ columns.append(
255
+ ColumnSpec(
256
+ name=raw_column["name"],
257
+ data_type=raw_column["data_type"],
258
+ role=raw_column.get("role", "feature"),
259
+ nullable=bool(raw_column.get("nullable", False)),
260
+ missing_value_policy=raw_column.get("missing_value_policy", "forbid"),
261
+ allowed_values=tuple(
262
+ str(value) for value in raw_column.get("allowed_values", [])
263
+ ),
264
+ minimum=raw_column.get("minimum"),
265
+ maximum=raw_column.get("maximum"),
266
+ )
267
+ )
268
+
269
+ return DatasetSchema(columns=tuple(columns))
270
+
271
+
272
+ def _load_generation_steps(raw_steps: object) -> tuple[GenerationStep, ...]:
273
+ if not isinstance(raw_steps, list):
274
+ raise ValueError("generation spec steps must be a list")
275
+
276
+ steps = []
277
+ for raw_step in raw_steps:
278
+ if not isinstance(raw_step, dict):
279
+ raise ValueError("generation steps must be mappings")
280
+
281
+ kind = raw_step["kind"]
282
+ source_column = raw_step.get("source_column")
283
+ if source_column is None and kind in _SOURCE_REQUIRED_STEP_KINDS:
284
+ source_column = raw_step["output_column"]
285
+
286
+ steps.append(
287
+ GenerationStep(
288
+ name=raw_step["name"],
289
+ kind=kind,
290
+ output_column=raw_step["output_column"],
291
+ source_column=source_column,
292
+ parent_columns=tuple(raw_step.get("parent_columns", [])),
293
+ input_column=raw_step.get("input_column"),
294
+ mean_shift=float(cast(Any, raw_step.get("mean_shift", 0.0))),
295
+ coefficient_sum=_load_coefficient_sum(raw_step.get("coefficient_sum")),
296
+ response=_load_response(raw_step.get("response")),
297
+ )
298
+ )
299
+
300
+ return tuple(steps)
301
+
302
+
303
+ def _load_model_backend(
304
+ raw_model_backend: object,
305
+ raw_spec: dict[str, object],
306
+ ) -> ModelBackendSpec:
307
+ if raw_model_backend is None:
308
+ n_estimators_raw = raw_spec.get("lgb_n_estimators", 100)
309
+ verbosity_raw = raw_spec.get("lgb_verbosity", -1)
310
+ return ModelBackendSpec(
311
+ kind="lightgbm",
312
+ params={
313
+ "n_estimators": int(cast(Any, n_estimators_raw)),
314
+ "verbosity": int(cast(Any, verbosity_raw)),
315
+ },
316
+ )
317
+
318
+ if not isinstance(raw_model_backend, dict):
319
+ raise ValueError("model_backend must be a mapping when provided")
320
+
321
+ raw_params = raw_model_backend.get("params", {})
322
+ if not isinstance(raw_params, dict):
323
+ raise ValueError("model_backend.params must be a mapping when provided")
324
+
325
+ return ModelBackendSpec(
326
+ kind=str(raw_model_backend.get("kind", "")).strip(),
327
+ params={str(key): value for key, value in raw_params.items()},
328
+ )
329
+
330
+
331
+ def _load_coefficient_sum(raw_coefficient_sum: object) -> CoefficientSumSpec | None:
332
+ if raw_coefficient_sum is None:
333
+ return None
334
+ if not isinstance(raw_coefficient_sum, dict):
335
+ raise ValueError("coefficient_sum must be a mapping when provided")
336
+
337
+ raw_terms = raw_coefficient_sum.get("terms", [])
338
+ if not isinstance(raw_terms, list):
339
+ raise ValueError("coefficient_sum.terms must be a list when provided")
340
+
341
+ terms = []
342
+ for raw_term in raw_terms:
343
+ if not isinstance(raw_term, dict):
344
+ raise ValueError("coefficient_sum terms must be mappings")
345
+ terms.append(
346
+ CoefficientTerm(
347
+ feature_name=raw_term["feature_name"],
348
+ coefficient=float(cast(Any, raw_term["coefficient"])),
349
+ value=str(raw_term["value"]) if "value" in raw_term else None,
350
+ )
351
+ )
352
+
353
+ return CoefficientSumSpec(
354
+ intercept=float(cast(Any, raw_coefficient_sum.get("intercept", 0.0))),
355
+ reference_value=str(raw_coefficient_sum.get("reference_value", "99")),
356
+ terms=tuple(terms),
357
+ )
358
+
359
+
360
+ def _load_response(raw_response: object) -> BernoulliResponseSpec | None:
361
+ if raw_response is None:
362
+ return None
363
+ if not isinstance(raw_response, dict):
364
+ raise ValueError("response must be a mapping when provided")
365
+
366
+ return BernoulliResponseSpec(
367
+ intercept=float(cast(Any, raw_response.get("intercept", 0.0))),
368
+ slope=float(cast(Any, raw_response.get("slope", 1.0))),
369
+ )
370
+
371
+
372
+ def _column_spec_to_dict(column: ColumnSpec) -> dict[str, object]:
373
+ payload: dict[str, object] = {
374
+ "name": column.name,
375
+ "data_type": column.data_type,
376
+ "role": column.role,
377
+ "nullable": column.nullable,
378
+ "missing_value_policy": column.missing_value_policy,
379
+ }
380
+ if column.allowed_values:
381
+ payload["allowed_values"] = list(column.allowed_values)
382
+ if column.minimum is not None:
383
+ payload["minimum"] = column.minimum
384
+ if column.maximum is not None:
385
+ payload["maximum"] = column.maximum
386
+ return payload
387
+
388
+
389
+ def _generation_step_to_dict(step: GenerationStep) -> dict[str, object]:
390
+ payload: dict[str, object] = {
391
+ "name": step.name,
392
+ "kind": step.kind,
393
+ "output_column": step.output_column,
394
+ }
395
+ if step.source_column is not None and step.source_column != step.output_column:
396
+ payload["source_column"] = step.source_column
397
+ if step.source_column is not None and step.kind in _SOURCE_REQUIRED_STEP_KINDS and step.source_column == step.output_column:
398
+ pass
399
+ if step.parent_columns:
400
+ payload["parent_columns"] = list(step.parent_columns)
401
+ if step.input_column is not None:
402
+ payload["input_column"] = step.input_column
403
+ if step.mean_shift != 0.0:
404
+ payload["mean_shift"] = step.mean_shift
405
+ if step.coefficient_sum is not None:
406
+ payload["coefficient_sum"] = _coefficient_sum_to_dict(step.coefficient_sum)
407
+ if step.response is not None:
408
+ payload["response"] = _response_to_dict(step.response)
409
+ return payload
410
+
411
+
412
+ def _coefficient_sum_to_dict(coefficient_sum: CoefficientSumSpec) -> dict[str, object]:
413
+ payload: dict[str, object] = {
414
+ "intercept": coefficient_sum.intercept,
415
+ "reference_value": coefficient_sum.reference_value,
416
+ "terms": [],
417
+ }
418
+ payload["terms"] = [
419
+ _coefficient_term_to_dict(term) for term in coefficient_sum.terms
420
+ ]
421
+ return payload
422
+
423
+
424
+ def _coefficient_term_to_dict(term: CoefficientTerm) -> dict[str, object]:
425
+ payload: dict[str, object] = {
426
+ "feature_name": term.feature_name,
427
+ "coefficient": term.coefficient,
428
+ }
429
+ if term.value is not None:
430
+ payload["value"] = term.value
431
+ return payload
432
+
433
+
434
+ def _response_to_dict(response: BernoulliResponseSpec) -> dict[str, object]:
435
+ return {
436
+ "intercept": response.intercept,
437
+ "slope": response.slope,
438
+ }
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ import lightgbm as lgb
7
+ import numpy as np
8
+ import pandas as pd
9
+ import polars as pl
10
+ from sklearn.linear_model import LogisticRegression
11
+
12
+ from tabdat_synth.domain.contracts import (
13
+ ModelBackendSpec,
14
+ )
15
+
16
+ ModelBackendFactory = Callable[
17
+ [pl.DataFrame, pl.Series, ModelBackendSpec, int],
18
+ Any,
19
+ ]
20
+
21
+ _MODEL_BACKEND_REGISTRY: dict[str, ModelBackendFactory] = {}
22
+
23
+
24
+ def register_model_backend(name: str, factory: ModelBackendFactory) -> None:
25
+ """Register a categorical-model backend for Python-side use."""
26
+ backend_name = name.strip()
27
+ if not backend_name:
28
+ raise ValueError("model backend name must be non-empty")
29
+ _MODEL_BACKEND_REGISTRY[backend_name] = factory
30
+
31
+
32
+ def unregister_model_backend(name: str) -> None:
33
+ """Remove a previously registered categorical-model backend."""
34
+ _MODEL_BACKEND_REGISTRY.pop(name, None)
35
+
36
+
37
+ def list_model_backends() -> tuple[str, ...]:
38
+ """Return the available categorical-model backend names."""
39
+ return tuple(sorted(_MODEL_BACKEND_REGISTRY))
40
+
41
+
42
+ def require_model_backend(name: str) -> ModelBackendFactory:
43
+ """Resolve a registered model backend or raise a helpful error."""
44
+ try:
45
+ return _MODEL_BACKEND_REGISTRY[name]
46
+ except KeyError as exc:
47
+ available = ", ".join(list_model_backends()) or "<none>"
48
+ raise ValueError(
49
+ f"model backend '{name}' is not registered; available backends: {available}"
50
+ ) from exc
51
+
52
+
53
+ def fit_categorical_model(
54
+ X: pl.DataFrame,
55
+ y: pl.Series,
56
+ model_backend: ModelBackendSpec,
57
+ random_seed: int,
58
+ ) -> Any:
59
+ """Fit a registered categorical-model backend."""
60
+ factory = require_model_backend(model_backend.kind)
61
+ model = factory(X, y, model_backend, random_seed)
62
+ setattr(model, "_sdg_backend_kind", model_backend.kind)
63
+ if not hasattr(model, "_sdg_feature_order"):
64
+ raise ValueError(
65
+ f"model backend '{model_backend.kind}' did not set _sdg_feature_order"
66
+ )
67
+ return model
68
+
69
+
70
+ def sample_categorical_feature(
71
+ model: Any,
72
+ X: pl.DataFrame,
73
+ learned_categories: list[str],
74
+ feature_idx: int,
75
+ seed: int,
76
+ feature_order: tuple[str, ...] | None = None,
77
+ ) -> list[str]:
78
+ """Sample categorical values from predicted class probabilities."""
79
+ resolved_feature_order = feature_order or getattr(model, "_sdg_feature_order", None)
80
+ if resolved_feature_order is None:
81
+ raise ValueError("fitted model is missing encoded feature order metadata")
82
+
83
+ X_encoded, _ = _encode_feature_frame(X, resolved_feature_order)
84
+ probs = np.asarray(model.predict_proba(X_encoded), dtype=float)
85
+ feature_rng = np.random.default_rng(seed=seed + feature_idx)
86
+
87
+ sampled_indices = [
88
+ int(feature_rng.choice(len(learned_categories), p=prob_row))
89
+ for prob_row in probs
90
+ ]
91
+ return [learned_categories[idx] for idx in sampled_indices]
92
+
93
+
94
+ def _fit_lightgbm_backend(
95
+ X: pl.DataFrame,
96
+ y: pl.Series,
97
+ model_backend: ModelBackendSpec,
98
+ random_seed: int,
99
+ ) -> lgb.LGBMClassifier:
100
+ params = model_backend.params
101
+ X_encoded, feature_order = _encode_feature_frame(X)
102
+ y_encoded = y.cast(pl.String).to_pandas()
103
+ n_categories = len(y.unique())
104
+
105
+ if n_categories == 2:
106
+ model = lgb.LGBMClassifier(
107
+ objective="binary",
108
+ random_state=random_seed,
109
+ verbosity=int(params.get("verbosity", -1)),
110
+ n_estimators=int(params.get("n_estimators", 100)),
111
+ )
112
+ else:
113
+ model = lgb.LGBMClassifier(
114
+ objective="multiclass",
115
+ num_class=n_categories,
116
+ random_state=random_seed,
117
+ verbosity=int(params.get("verbosity", -1)),
118
+ n_estimators=int(params.get("n_estimators", 100)),
119
+ )
120
+
121
+ model.fit(X_encoded, y_encoded)
122
+ setattr(model, "_sdg_feature_order", feature_order)
123
+ return model
124
+
125
+
126
+ def _fit_logistic_regression_backend(
127
+ X: pl.DataFrame,
128
+ y: pl.Series,
129
+ model_backend: ModelBackendSpec,
130
+ random_seed: int,
131
+ ) -> LogisticRegression:
132
+ params = model_backend.params
133
+ X_encoded, feature_order = _encode_feature_frame(X)
134
+ y_encoded = y.cast(pl.String).to_pandas()
135
+
136
+ model = LogisticRegression(
137
+ C=float(params.get("C", 1.0)),
138
+ max_iter=int(params.get("max_iter", 500)),
139
+ random_state=random_seed,
140
+ solver=str(params.get("solver", "lbfgs")),
141
+ )
142
+ model.fit(X_encoded, y_encoded)
143
+ setattr(model, "_sdg_feature_order", feature_order)
144
+ return model
145
+
146
+
147
+ def _encode_feature_frame(
148
+ X: pl.DataFrame,
149
+ feature_order: tuple[str, ...] | None = None,
150
+ ) -> tuple[pd.DataFrame, tuple[str, ...]]:
151
+ encoded = pd.get_dummies(X.to_pandas(), dtype=float)
152
+ encoded = encoded.astype(float)
153
+
154
+ if feature_order is None:
155
+ resolved_order = tuple(str(column) for column in encoded.columns)
156
+ return encoded, resolved_order
157
+
158
+ resolved = encoded.reindex(columns=list(feature_order), fill_value=0.0)
159
+ return resolved.astype(float), feature_order
160
+
161
+
162
+ register_model_backend("lightgbm", _fit_lightgbm_backend)
163
+ register_model_backend("logistic_regression", _fit_logistic_regression_backend)
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from dataclasses import is_dataclass
5
+ import json
6
+ import math
7
+ from pathlib import Path
8
+
9
+ from tabdat_synth.domain.contracts import (
10
+ EvaluationReport,
11
+ MetricResult,
12
+ )
13
+
14
+
15
+ def evaluation_report_to_dict(report: EvaluationReport) -> dict[str, object]:
16
+ """Convert an evaluation report into a JSON-serializable mapping."""
17
+ return {
18
+ "source_row_count": report.source_row_count,
19
+ "synthetic_row_count": report.synthetic_row_count,
20
+ "evaluated_columns": list(report.evaluated_columns),
21
+ "skipped_columns": list(report.skipped_columns),
22
+ "quality_metrics": [
23
+ _metric_result_to_dict(metric) for metric in report.quality_metrics
24
+ ],
25
+ "disclosure_risk_metrics": [
26
+ _metric_result_to_dict(metric)
27
+ for metric in report.disclosure_risk_metrics
28
+ ],
29
+ }
30
+
31
+
32
+ def save_evaluation_report(report: EvaluationReport, path: Path | str) -> Path:
33
+ """Write an evaluation report to JSON."""
34
+ report_path = Path(path).resolve()
35
+ report_path.parent.mkdir(parents=True, exist_ok=True)
36
+ report_path.write_text(
37
+ json.dumps(evaluation_report_to_dict(report), indent=2, sort_keys=True) + "\n"
38
+ )
39
+ return report_path
40
+
41
+
42
+ def _metric_result_to_dict(metric: MetricResult) -> dict[str, object]:
43
+ payload: dict[str, object] = {
44
+ "category": metric.category,
45
+ "name": metric.name,
46
+ "values": _jsonable(metric.values),
47
+ "details": _jsonable(metric.details),
48
+ }
49
+ if metric.column is not None:
50
+ payload["column"] = metric.column
51
+ return payload
52
+
53
+
54
+ def _jsonable(value: object) -> object:
55
+ if value is None or isinstance(value, str | int | bool):
56
+ return value
57
+ if isinstance(value, float):
58
+ if math.isfinite(value):
59
+ return value
60
+ return None
61
+ if isinstance(value, Mapping):
62
+ return {str(key): _jsonable(item) for key, item in value.items()}
63
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
64
+ return [_jsonable(item) for item in value]
65
+ if is_dataclass(value):
66
+ return _jsonable(value.__dict__)
67
+ return str(value)
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,19 @@
1
+ """Pipeline orchestration for synthetic data generation."""
2
+
3
+ from tabdat_synth.workflows.evaluation import (
4
+ evaluate_from_spec,
5
+ evaluate_synthetic_data,
6
+ )
7
+ from tabdat_synth.workflows.pipelines import (
8
+ fit_synthesizer,
9
+ generate_from_fitted,
10
+ generate_from_spec,
11
+ )
12
+
13
+ __all__ = [
14
+ "evaluate_from_spec",
15
+ "evaluate_synthetic_data",
16
+ "fit_synthesizer",
17
+ "generate_from_fitted",
18
+ "generate_from_spec",
19
+ ]