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,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal, TypeAlias
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
ColumnDataType = Literal["categorical", "integer", "float"]
|
|
9
|
+
ColumnRole = Literal["feature", "outcome", "identifier"]
|
|
10
|
+
MissingValuePolicy = Literal["forbid", "as_category", "mean_impute"]
|
|
11
|
+
SourceFormat = Literal["parquet", "csv", "zip_csv"]
|
|
12
|
+
GenerationStepKind = Literal[
|
|
13
|
+
"empirical",
|
|
14
|
+
"truncated_normal",
|
|
15
|
+
"categorical_model",
|
|
16
|
+
"coefficient_sum",
|
|
17
|
+
"sigmoid_probability",
|
|
18
|
+
"bernoulli_response",
|
|
19
|
+
]
|
|
20
|
+
BackendParamValue: TypeAlias = str | int | float | bool
|
|
21
|
+
BackendScalarValue: TypeAlias = str | int | float
|
|
22
|
+
MetricScalar: TypeAlias = str | int | float | bool | None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class FreqShift:
|
|
27
|
+
"""Categorical frequency shift in percentage points."""
|
|
28
|
+
|
|
29
|
+
source: str
|
|
30
|
+
dest: str
|
|
31
|
+
shift_pct: float
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ModelBackendSpec:
|
|
36
|
+
"""Configuration for the categorical-model backend used by a spec."""
|
|
37
|
+
|
|
38
|
+
kind: str = "lightgbm"
|
|
39
|
+
params: dict[str, BackendParamValue] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class CoefficientTerm:
|
|
44
|
+
"""One term in a coefficient-based weighted sum."""
|
|
45
|
+
|
|
46
|
+
feature_name: str
|
|
47
|
+
coefficient: float
|
|
48
|
+
value: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class CoefficientSumSpec:
|
|
53
|
+
"""Typed configuration for coefficient-based weighted sums."""
|
|
54
|
+
|
|
55
|
+
intercept: float = 0.0
|
|
56
|
+
reference_value: str = "99"
|
|
57
|
+
terms: tuple[CoefficientTerm, ...] = field(default_factory=tuple)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class BernoulliResponseSpec:
|
|
62
|
+
"""Typed response-adjustment configuration for Bernoulli sampling."""
|
|
63
|
+
|
|
64
|
+
intercept: float = 0.0
|
|
65
|
+
slope: float = 1.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class ColumnSpec:
|
|
70
|
+
"""Declarative schema metadata for one source or generated column."""
|
|
71
|
+
|
|
72
|
+
name: str
|
|
73
|
+
data_type: ColumnDataType
|
|
74
|
+
role: ColumnRole = "feature"
|
|
75
|
+
nullable: bool = False
|
|
76
|
+
missing_value_policy: MissingValuePolicy = "forbid"
|
|
77
|
+
allowed_values: tuple[str, ...] = field(default_factory=tuple)
|
|
78
|
+
minimum: float | None = None
|
|
79
|
+
maximum: float | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class DatasetSchema:
|
|
84
|
+
"""Schema contract for a configurable source dataset."""
|
|
85
|
+
|
|
86
|
+
columns: tuple[ColumnSpec, ...]
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def columns_by_name(self) -> dict[str, ColumnSpec]:
|
|
90
|
+
return {column.name: column for column in self.columns}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class GenerationStep:
|
|
95
|
+
"""Declarative description of one synthetic feature-generation step."""
|
|
96
|
+
|
|
97
|
+
name: str
|
|
98
|
+
kind: GenerationStepKind
|
|
99
|
+
output_column: str
|
|
100
|
+
source_column: str | None = None
|
|
101
|
+
parent_columns: tuple[str, ...] = field(default_factory=tuple)
|
|
102
|
+
input_column: str | None = None
|
|
103
|
+
mean_shift: float = 0.0
|
|
104
|
+
coefficient_sum: CoefficientSumSpec | None = None
|
|
105
|
+
response: BernoulliResponseSpec | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class GenerationSpec:
|
|
110
|
+
"""Top-level specification for config-driven synthetic generation."""
|
|
111
|
+
|
|
112
|
+
name: str
|
|
113
|
+
source_path: Path
|
|
114
|
+
source_format: SourceFormat
|
|
115
|
+
schema: DatasetSchema
|
|
116
|
+
steps: tuple[GenerationStep, ...]
|
|
117
|
+
model_backend: ModelBackendSpec = field(default_factory=ModelBackendSpec)
|
|
118
|
+
n_samples: int = 100_000
|
|
119
|
+
random_seed: int = 343
|
|
120
|
+
source_member: str | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class EmpiricalStepArtifact:
|
|
125
|
+
"""Persisted empirical distribution for a fitted step."""
|
|
126
|
+
|
|
127
|
+
step: GenerationStep
|
|
128
|
+
values: tuple[BackendScalarValue, ...]
|
|
129
|
+
probabilities: tuple[float, ...]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class TruncatedNormalStepArtifact:
|
|
134
|
+
"""Persisted numeric summary statistics for a fitted step."""
|
|
135
|
+
|
|
136
|
+
step: GenerationStep
|
|
137
|
+
observed_mean: float
|
|
138
|
+
observed_std: float
|
|
139
|
+
minimum: float
|
|
140
|
+
maximum: float
|
|
141
|
+
round_to_int: bool
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class CategoricalModelStepArtifact:
|
|
146
|
+
"""Persisted model state for a fitted categorical-model step."""
|
|
147
|
+
|
|
148
|
+
step: GenerationStep
|
|
149
|
+
backend_kind: str
|
|
150
|
+
input_columns: tuple[str, ...]
|
|
151
|
+
feature_order: tuple[str, ...]
|
|
152
|
+
learned_categories: tuple[str, ...]
|
|
153
|
+
fitted_model: Any
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class ConfiguredStepArtifact:
|
|
158
|
+
"""Persisted configuration-only step with no source-side fitted state."""
|
|
159
|
+
|
|
160
|
+
step: GenerationStep
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
FittedStepArtifact: TypeAlias = (
|
|
164
|
+
EmpiricalStepArtifact
|
|
165
|
+
| TruncatedNormalStepArtifact
|
|
166
|
+
| CategoricalModelStepArtifact
|
|
167
|
+
| ConfiguredStepArtifact
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class FittedSynthesizer:
|
|
173
|
+
"""Fitted synthesizer bundle that can generate without source data."""
|
|
174
|
+
|
|
175
|
+
spec: GenerationSpec
|
|
176
|
+
step_artifacts: tuple[FittedStepArtifact, ...]
|
|
177
|
+
package_version: str
|
|
178
|
+
bundle_format_version: int = 2
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class MetricResult:
|
|
183
|
+
"""One quality or disclosure-risk metric in an evaluation report."""
|
|
184
|
+
|
|
185
|
+
category: str
|
|
186
|
+
name: str
|
|
187
|
+
column: str | None = None
|
|
188
|
+
values: dict[str, MetricScalar] = field(default_factory=dict)
|
|
189
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class EvaluationReport:
|
|
194
|
+
"""Evaluation report comparing source and synthetic tabular data."""
|
|
195
|
+
|
|
196
|
+
source_row_count: int
|
|
197
|
+
synthetic_row_count: int
|
|
198
|
+
evaluated_columns: tuple[str, ...]
|
|
199
|
+
skipped_columns: tuple[str, ...] = field(default_factory=tuple)
|
|
200
|
+
quality_metrics: tuple[MetricResult, ...] = field(default_factory=tuple)
|
|
201
|
+
disclosure_risk_metrics: tuple[MetricResult, ...] = field(default_factory=tuple)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Boundary adapters for IO and model backends."""
|
|
2
|
+
|
|
3
|
+
from tabdat_synth.interfaces.bundles import (
|
|
4
|
+
load_fitted_synthesizer,
|
|
5
|
+
save_fitted_synthesizer,
|
|
6
|
+
)
|
|
7
|
+
from tabdat_synth.interfaces.io import (
|
|
8
|
+
generation_spec_to_dict,
|
|
9
|
+
load_generation_spec,
|
|
10
|
+
load_generation_spec_mapping,
|
|
11
|
+
load_source_dataframe,
|
|
12
|
+
)
|
|
13
|
+
from tabdat_synth.interfaces.models import (
|
|
14
|
+
list_model_backends,
|
|
15
|
+
register_model_backend,
|
|
16
|
+
unregister_model_backend,
|
|
17
|
+
)
|
|
18
|
+
from tabdat_synth.interfaces.reports import (
|
|
19
|
+
evaluation_report_to_dict,
|
|
20
|
+
save_evaluation_report,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"evaluation_report_to_dict",
|
|
25
|
+
"generation_spec_to_dict",
|
|
26
|
+
"list_model_backends",
|
|
27
|
+
"load_fitted_synthesizer",
|
|
28
|
+
"load_generation_spec",
|
|
29
|
+
"load_generation_spec_mapping",
|
|
30
|
+
"load_source_dataframe",
|
|
31
|
+
"register_model_backend",
|
|
32
|
+
"save_evaluation_report",
|
|
33
|
+
"save_fitted_synthesizer",
|
|
34
|
+
"unregister_model_backend",
|
|
35
|
+
]
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
import hashlib
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import pickle
|
|
9
|
+
import platform
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
import yaml # type: ignore[import-untyped]
|
|
13
|
+
|
|
14
|
+
from tabdat_synth.domain.contracts import (
|
|
15
|
+
BernoulliResponseSpec,
|
|
16
|
+
CategoricalModelStepArtifact,
|
|
17
|
+
CoefficientSumSpec,
|
|
18
|
+
CoefficientTerm,
|
|
19
|
+
ConfiguredStepArtifact,
|
|
20
|
+
EmpiricalStepArtifact,
|
|
21
|
+
FittedStepArtifact,
|
|
22
|
+
FittedSynthesizer,
|
|
23
|
+
GenerationStep,
|
|
24
|
+
TruncatedNormalStepArtifact,
|
|
25
|
+
)
|
|
26
|
+
from tabdat_synth.interfaces.io import (
|
|
27
|
+
generation_spec_to_dict,
|
|
28
|
+
load_generation_spec_mapping,
|
|
29
|
+
)
|
|
30
|
+
from tabdat_synth.interfaces.models import require_model_backend
|
|
31
|
+
from tabdat_synth.version import __version__
|
|
32
|
+
|
|
33
|
+
BUNDLE_FORMAT_VERSION = 2
|
|
34
|
+
_METADATA_FILENAME = "metadata.json"
|
|
35
|
+
_SPEC_FILENAME = "spec.yaml"
|
|
36
|
+
_STEPS_DIRNAME = "steps"
|
|
37
|
+
_SOURCE_REQUIRED_STEP_KINDS = {"empirical", "truncated_normal", "categorical_model"}
|
|
38
|
+
_METADATA_DEPENDENCIES = (
|
|
39
|
+
"lightgbm",
|
|
40
|
+
"numpy",
|
|
41
|
+
"pandas",
|
|
42
|
+
"polars",
|
|
43
|
+
"pyarrow",
|
|
44
|
+
"PyYAML",
|
|
45
|
+
"scikit-learn",
|
|
46
|
+
"scipy",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_fitted_synthesizer(
|
|
51
|
+
fitted: FittedSynthesizer,
|
|
52
|
+
path: Path | str,
|
|
53
|
+
) -> Path:
|
|
54
|
+
"""Persist a fitted synthesizer bundle directory to disk."""
|
|
55
|
+
bundle_path = Path(path).resolve()
|
|
56
|
+
_prepare_bundle_directory(bundle_path)
|
|
57
|
+
|
|
58
|
+
steps_dir = bundle_path / _STEPS_DIRNAME
|
|
59
|
+
steps_dir.mkdir()
|
|
60
|
+
|
|
61
|
+
metadata = _build_bundle_metadata(fitted)
|
|
62
|
+
(bundle_path / _METADATA_FILENAME).write_text(
|
|
63
|
+
json.dumps(metadata, indent=2, sort_keys=True)
|
|
64
|
+
)
|
|
65
|
+
(bundle_path / _SPEC_FILENAME).write_text(
|
|
66
|
+
yaml.safe_dump(generation_spec_to_dict(fitted.spec), sort_keys=False)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
for index, artifact in enumerate(fitted.step_artifacts):
|
|
70
|
+
_write_step_artifact(steps_dir, index, artifact)
|
|
71
|
+
|
|
72
|
+
return bundle_path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_bundle_metadata(fitted: FittedSynthesizer) -> dict[str, object]:
|
|
76
|
+
spec_payload = generation_spec_to_dict(fitted.spec)
|
|
77
|
+
return {
|
|
78
|
+
"bundle_format_version": fitted.bundle_format_version,
|
|
79
|
+
"package_version": fitted.package_version,
|
|
80
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
81
|
+
"step_count": len(fitted.step_artifacts),
|
|
82
|
+
"artifact_counts": _artifact_counts(fitted.step_artifacts),
|
|
83
|
+
"dependency_versions": _dependency_versions(),
|
|
84
|
+
"model_backend": {
|
|
85
|
+
"kind": fitted.spec.model_backend.kind,
|
|
86
|
+
"params": dict(fitted.spec.model_backend.params),
|
|
87
|
+
},
|
|
88
|
+
"platform": {
|
|
89
|
+
"machine": platform.machine(),
|
|
90
|
+
"platform": platform.platform(),
|
|
91
|
+
"release": platform.release(),
|
|
92
|
+
"system": platform.system(),
|
|
93
|
+
},
|
|
94
|
+
"python": {
|
|
95
|
+
"implementation": platform.python_implementation(),
|
|
96
|
+
"version": platform.python_version(),
|
|
97
|
+
},
|
|
98
|
+
"schema_hash": _stable_payload_hash(spec_payload["schema"]),
|
|
99
|
+
"source": {
|
|
100
|
+
"format": fitted.spec.source_format,
|
|
101
|
+
"member": fitted.spec.source_member,
|
|
102
|
+
},
|
|
103
|
+
"spec_hash": _stable_payload_hash(spec_payload),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _dependency_versions() -> dict[str, str | None]:
|
|
108
|
+
versions: dict[str, str | None] = {}
|
|
109
|
+
for package_name in _METADATA_DEPENDENCIES:
|
|
110
|
+
try:
|
|
111
|
+
versions[package_name] = version(package_name)
|
|
112
|
+
except PackageNotFoundError:
|
|
113
|
+
versions[package_name] = None
|
|
114
|
+
return versions
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _stable_payload_hash(payload: object) -> str:
|
|
118
|
+
encoded = json.dumps(
|
|
119
|
+
payload,
|
|
120
|
+
default=str,
|
|
121
|
+
separators=(",", ":"),
|
|
122
|
+
sort_keys=True,
|
|
123
|
+
).encode("utf-8")
|
|
124
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _artifact_counts(artifacts: tuple[FittedStepArtifact, ...]) -> dict[str, int]:
|
|
128
|
+
counts: dict[str, int] = {}
|
|
129
|
+
for artifact in artifacts:
|
|
130
|
+
artifact_kind = _artifact_kind(artifact)
|
|
131
|
+
counts[artifact_kind] = counts.get(artifact_kind, 0) + 1
|
|
132
|
+
return counts
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _artifact_kind(artifact: FittedStepArtifact) -> str:
|
|
136
|
+
if isinstance(artifact, EmpiricalStepArtifact):
|
|
137
|
+
return "empirical"
|
|
138
|
+
if isinstance(artifact, TruncatedNormalStepArtifact):
|
|
139
|
+
return "truncated_normal"
|
|
140
|
+
if isinstance(artifact, CategoricalModelStepArtifact):
|
|
141
|
+
return "categorical_model"
|
|
142
|
+
if isinstance(artifact, ConfiguredStepArtifact):
|
|
143
|
+
return "configured"
|
|
144
|
+
raise ValueError(f"unsupported fitted step artifact type: {type(artifact)}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def load_fitted_synthesizer(path: Path | str) -> FittedSynthesizer:
|
|
148
|
+
"""Load a persisted fitted synthesizer bundle from disk."""
|
|
149
|
+
bundle_path = Path(path).resolve()
|
|
150
|
+
metadata_path = bundle_path / _METADATA_FILENAME
|
|
151
|
+
spec_path = bundle_path / _SPEC_FILENAME
|
|
152
|
+
steps_dir = bundle_path / _STEPS_DIRNAME
|
|
153
|
+
|
|
154
|
+
for required_path in (metadata_path, spec_path, steps_dir):
|
|
155
|
+
if not required_path.exists():
|
|
156
|
+
raise ValueError(f"fitted bundle is missing required path: {required_path}")
|
|
157
|
+
|
|
158
|
+
metadata = json.loads(metadata_path.read_text())
|
|
159
|
+
bundle_format_version = int(metadata["bundle_format_version"])
|
|
160
|
+
if bundle_format_version != BUNDLE_FORMAT_VERSION:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
"fitted bundle format version mismatch: "
|
|
163
|
+
f"expected {BUNDLE_FORMAT_VERSION}, got {bundle_format_version}"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
package_version = str(metadata["package_version"])
|
|
167
|
+
if package_version != __version__:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
"fitted bundle package version mismatch: "
|
|
170
|
+
f"expected {__version__}, got {package_version}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
raw_spec = yaml.safe_load(spec_path.read_text())
|
|
174
|
+
spec = load_generation_spec_mapping(raw_spec)
|
|
175
|
+
step_payload_paths = sorted(steps_dir.glob("*.json"))
|
|
176
|
+
expected_step_count = int(metadata["step_count"])
|
|
177
|
+
if len(step_payload_paths) != expected_step_count:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
"fitted bundle step metadata count mismatch: "
|
|
180
|
+
f"expected {expected_step_count}, found {len(step_payload_paths)}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
step_artifacts = tuple(
|
|
184
|
+
_read_step_artifact(steps_dir, payload_path)
|
|
185
|
+
for payload_path in step_payload_paths
|
|
186
|
+
)
|
|
187
|
+
return FittedSynthesizer(
|
|
188
|
+
spec=spec,
|
|
189
|
+
step_artifacts=step_artifacts,
|
|
190
|
+
package_version=package_version,
|
|
191
|
+
bundle_format_version=bundle_format_version,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _prepare_bundle_directory(bundle_path: Path) -> None:
|
|
196
|
+
if bundle_path.exists():
|
|
197
|
+
if not bundle_path.is_dir():
|
|
198
|
+
raise ValueError(f"bundle path is not a directory: {bundle_path}")
|
|
199
|
+
if any(bundle_path.iterdir()):
|
|
200
|
+
raise ValueError(f"bundle path must be empty before saving: {bundle_path}")
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
bundle_path.mkdir(parents=True)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _write_step_artifact(
|
|
207
|
+
steps_dir: Path,
|
|
208
|
+
index: int,
|
|
209
|
+
artifact: FittedStepArtifact,
|
|
210
|
+
) -> None:
|
|
211
|
+
stem = f"{index:03d}_{_safe_token(artifact.step.output_column)}"
|
|
212
|
+
payload_path = steps_dir / f"{stem}.json"
|
|
213
|
+
|
|
214
|
+
if isinstance(artifact, EmpiricalStepArtifact):
|
|
215
|
+
empirical_payload: dict[str, object] = {
|
|
216
|
+
"artifact_kind": "empirical",
|
|
217
|
+
"step": _generation_step_to_dict(artifact.step),
|
|
218
|
+
"values": list(artifact.values),
|
|
219
|
+
"probabilities": list(artifact.probabilities),
|
|
220
|
+
}
|
|
221
|
+
payload_path.write_text(json.dumps(empirical_payload, indent=2, sort_keys=True))
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
if isinstance(artifact, TruncatedNormalStepArtifact):
|
|
225
|
+
truncated_payload: dict[str, object] = {
|
|
226
|
+
"artifact_kind": "truncated_normal",
|
|
227
|
+
"step": _generation_step_to_dict(artifact.step),
|
|
228
|
+
"observed_mean": artifact.observed_mean,
|
|
229
|
+
"observed_std": artifact.observed_std,
|
|
230
|
+
"minimum": artifact.minimum,
|
|
231
|
+
"maximum": artifact.maximum,
|
|
232
|
+
"round_to_int": artifact.round_to_int,
|
|
233
|
+
}
|
|
234
|
+
payload_path.write_text(json.dumps(truncated_payload, indent=2, sort_keys=True))
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
if isinstance(artifact, CategoricalModelStepArtifact):
|
|
238
|
+
model_filename = f"{stem}.pkl"
|
|
239
|
+
model_payload: dict[str, object] = {
|
|
240
|
+
"artifact_kind": "categorical_model",
|
|
241
|
+
"step": _generation_step_to_dict(artifact.step),
|
|
242
|
+
"backend_kind": artifact.backend_kind,
|
|
243
|
+
"input_columns": list(artifact.input_columns),
|
|
244
|
+
"feature_order": list(artifact.feature_order),
|
|
245
|
+
"learned_categories": list(artifact.learned_categories),
|
|
246
|
+
"model_filename": model_filename,
|
|
247
|
+
}
|
|
248
|
+
payload_path.write_text(json.dumps(model_payload, indent=2, sort_keys=True))
|
|
249
|
+
with (steps_dir / model_filename).open("wb") as handle:
|
|
250
|
+
pickle.dump(artifact.fitted_model, handle)
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
if not isinstance(artifact, ConfiguredStepArtifact):
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"unsupported step artifact type for bundle persistence: {type(artifact)}"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
configured_payload: dict[str, object] = {
|
|
259
|
+
"artifact_kind": "configured",
|
|
260
|
+
"step": _generation_step_to_dict(artifact.step),
|
|
261
|
+
}
|
|
262
|
+
payload_path.write_text(json.dumps(configured_payload, indent=2, sort_keys=True))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _read_step_artifact(steps_dir: Path, payload_path: Path) -> FittedStepArtifact:
|
|
266
|
+
payload = json.loads(payload_path.read_text())
|
|
267
|
+
artifact_kind = payload["artifact_kind"]
|
|
268
|
+
step = _load_generation_step(payload["step"])
|
|
269
|
+
|
|
270
|
+
if artifact_kind == "empirical":
|
|
271
|
+
return EmpiricalStepArtifact(
|
|
272
|
+
step=step,
|
|
273
|
+
values=tuple(payload["values"]),
|
|
274
|
+
probabilities=tuple(float(value) for value in payload["probabilities"]),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if artifact_kind == "truncated_normal":
|
|
278
|
+
return TruncatedNormalStepArtifact(
|
|
279
|
+
step=step,
|
|
280
|
+
observed_mean=float(payload["observed_mean"]),
|
|
281
|
+
observed_std=float(payload["observed_std"]),
|
|
282
|
+
minimum=float(payload["minimum"]),
|
|
283
|
+
maximum=float(payload["maximum"]),
|
|
284
|
+
round_to_int=bool(payload["round_to_int"]),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
if artifact_kind == "configured":
|
|
288
|
+
return ConfiguredStepArtifact(step=step)
|
|
289
|
+
|
|
290
|
+
if artifact_kind != "categorical_model":
|
|
291
|
+
raise ValueError(
|
|
292
|
+
f"fitted bundle contains unsupported step artifact kind: {artifact_kind}"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
backend_kind = str(payload["backend_kind"])
|
|
296
|
+
require_model_backend(backend_kind)
|
|
297
|
+
model_path = steps_dir / str(payload["model_filename"])
|
|
298
|
+
if not model_path.exists():
|
|
299
|
+
raise ValueError(f"fitted bundle is missing required model file: {model_path}")
|
|
300
|
+
|
|
301
|
+
with model_path.open("rb") as handle:
|
|
302
|
+
fitted_model = pickle.load(handle)
|
|
303
|
+
|
|
304
|
+
return CategoricalModelStepArtifact(
|
|
305
|
+
step=step,
|
|
306
|
+
backend_kind=backend_kind,
|
|
307
|
+
input_columns=tuple(str(value) for value in payload["input_columns"]),
|
|
308
|
+
feature_order=tuple(str(value) for value in payload["feature_order"]),
|
|
309
|
+
learned_categories=tuple(str(value) for value in payload["learned_categories"]),
|
|
310
|
+
fitted_model=fitted_model,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _load_generation_step(raw_step: object) -> GenerationStep:
|
|
315
|
+
if not isinstance(raw_step, dict):
|
|
316
|
+
raise ValueError("bundle step payload must be a mapping")
|
|
317
|
+
|
|
318
|
+
kind = raw_step["kind"]
|
|
319
|
+
source_column = raw_step.get("source_column")
|
|
320
|
+
if source_column is None and kind in _SOURCE_REQUIRED_STEP_KINDS:
|
|
321
|
+
source_column = raw_step["output_column"]
|
|
322
|
+
|
|
323
|
+
return GenerationStep(
|
|
324
|
+
name=raw_step["name"],
|
|
325
|
+
kind=kind,
|
|
326
|
+
output_column=raw_step["output_column"],
|
|
327
|
+
source_column=source_column,
|
|
328
|
+
parent_columns=tuple(raw_step.get("parent_columns", [])),
|
|
329
|
+
input_column=raw_step.get("input_column"),
|
|
330
|
+
mean_shift=float(raw_step.get("mean_shift", 0.0)),
|
|
331
|
+
coefficient_sum=_load_coefficient_sum(raw_step.get("coefficient_sum")),
|
|
332
|
+
response=_load_response(raw_step.get("response")),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _generation_step_to_dict(step: GenerationStep) -> dict[str, object]:
|
|
337
|
+
payload: dict[str, object] = {
|
|
338
|
+
"name": step.name,
|
|
339
|
+
"kind": step.kind,
|
|
340
|
+
"output_column": step.output_column,
|
|
341
|
+
}
|
|
342
|
+
if step.source_column is not None and step.source_column != step.output_column:
|
|
343
|
+
payload["source_column"] = step.source_column
|
|
344
|
+
if step.parent_columns:
|
|
345
|
+
payload["parent_columns"] = list(step.parent_columns)
|
|
346
|
+
if step.input_column is not None:
|
|
347
|
+
payload["input_column"] = step.input_column
|
|
348
|
+
if step.mean_shift != 0.0:
|
|
349
|
+
payload["mean_shift"] = step.mean_shift
|
|
350
|
+
if step.coefficient_sum is not None:
|
|
351
|
+
payload["coefficient_sum"] = _coefficient_sum_to_dict(step.coefficient_sum)
|
|
352
|
+
if step.response is not None:
|
|
353
|
+
payload["response"] = _response_to_dict(step.response)
|
|
354
|
+
return payload
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _load_coefficient_sum(raw_coefficient_sum: object) -> CoefficientSumSpec | None:
|
|
358
|
+
if raw_coefficient_sum is None:
|
|
359
|
+
return None
|
|
360
|
+
if not isinstance(raw_coefficient_sum, dict):
|
|
361
|
+
raise ValueError("bundle step coefficient_sum must be a mapping")
|
|
362
|
+
|
|
363
|
+
raw_terms = raw_coefficient_sum.get("terms", [])
|
|
364
|
+
if not isinstance(raw_terms, list):
|
|
365
|
+
raise ValueError("bundle step coefficient_sum.terms must be a list")
|
|
366
|
+
|
|
367
|
+
terms = []
|
|
368
|
+
for raw_term in raw_terms:
|
|
369
|
+
if not isinstance(raw_term, dict):
|
|
370
|
+
raise ValueError("bundle step coefficient_sum terms must be mappings")
|
|
371
|
+
terms.append(
|
|
372
|
+
CoefficientTerm(
|
|
373
|
+
feature_name=raw_term["feature_name"],
|
|
374
|
+
coefficient=float(raw_term["coefficient"]),
|
|
375
|
+
value=str(raw_term["value"]) if "value" in raw_term else None,
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
return CoefficientSumSpec(
|
|
380
|
+
intercept=float(raw_coefficient_sum.get("intercept", 0.0)),
|
|
381
|
+
reference_value=str(raw_coefficient_sum.get("reference_value", "99")),
|
|
382
|
+
terms=tuple(terms),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _coefficient_sum_to_dict(coefficient_sum: CoefficientSumSpec) -> dict[str, object]:
|
|
387
|
+
return {
|
|
388
|
+
"intercept": coefficient_sum.intercept,
|
|
389
|
+
"reference_value": coefficient_sum.reference_value,
|
|
390
|
+
"terms": [
|
|
391
|
+
_coefficient_term_to_dict(term) for term in coefficient_sum.terms
|
|
392
|
+
],
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _coefficient_term_to_dict(term: CoefficientTerm) -> dict[str, object]:
|
|
397
|
+
payload: dict[str, object] = {
|
|
398
|
+
"feature_name": term.feature_name,
|
|
399
|
+
"coefficient": term.coefficient,
|
|
400
|
+
}
|
|
401
|
+
if term.value is not None:
|
|
402
|
+
payload["value"] = term.value
|
|
403
|
+
return payload
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _load_response(raw_response: object) -> BernoulliResponseSpec | None:
|
|
407
|
+
if raw_response is None:
|
|
408
|
+
return None
|
|
409
|
+
if not isinstance(raw_response, dict):
|
|
410
|
+
raise ValueError("bundle step response must be a mapping")
|
|
411
|
+
return BernoulliResponseSpec(
|
|
412
|
+
intercept=float(raw_response.get("intercept", 0.0)),
|
|
413
|
+
slope=float(raw_response.get("slope", 1.0)),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _response_to_dict(response: BernoulliResponseSpec) -> dict[str, object]:
|
|
418
|
+
return {
|
|
419
|
+
"intercept": response.intercept,
|
|
420
|
+
"slope": response.slope,
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _safe_token(value: str) -> str:
|
|
425
|
+
return re.sub(r"[^A-Za-z0-9_.-]+", "_", value)
|