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,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)
|
tabdat_synth/version.py
ADDED
|
@@ -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
|
+
]
|