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,67 @@
1
+ """Synthetic data generator package."""
2
+
3
+ from tabdat_synth.version import __version__
4
+ from tabdat_synth.domain.contracts import (
5
+ BernoulliResponseSpec,
6
+ CategoricalModelStepArtifact,
7
+ ColumnSpec,
8
+ CoefficientSumSpec,
9
+ CoefficientTerm,
10
+ ConfiguredStepArtifact,
11
+ EmpiricalStepArtifact,
12
+ DatasetSchema,
13
+ EvaluationReport,
14
+ FittedSynthesizer,
15
+ GenerationSpec,
16
+ GenerationStep,
17
+ MetricResult,
18
+ ModelBackendSpec,
19
+ TruncatedNormalStepArtifact,
20
+ )
21
+ from tabdat_synth.interfaces.bundles import (
22
+ load_fitted_synthesizer,
23
+ save_fitted_synthesizer,
24
+ )
25
+ from tabdat_synth.interfaces.io import load_generation_spec
26
+ from tabdat_synth.interfaces.reports import (
27
+ evaluation_report_to_dict,
28
+ save_evaluation_report,
29
+ )
30
+ from tabdat_synth.workflows.evaluation import (
31
+ evaluate_from_spec,
32
+ evaluate_synthetic_data,
33
+ )
34
+ from tabdat_synth.workflows.pipelines import (
35
+ fit_synthesizer,
36
+ generate_from_fitted,
37
+ generate_from_spec,
38
+ )
39
+
40
+ __all__ = [
41
+ "__version__",
42
+ "BernoulliResponseSpec",
43
+ "CategoricalModelStepArtifact",
44
+ "ColumnSpec",
45
+ "CoefficientSumSpec",
46
+ "CoefficientTerm",
47
+ "ConfiguredStepArtifact",
48
+ "DatasetSchema",
49
+ "EmpiricalStepArtifact",
50
+ "EvaluationReport",
51
+ "MetricResult",
52
+ "FittedSynthesizer",
53
+ "GenerationSpec",
54
+ "GenerationStep",
55
+ "ModelBackendSpec",
56
+ "TruncatedNormalStepArtifact",
57
+ "evaluate_from_spec",
58
+ "evaluate_synthetic_data",
59
+ "evaluation_report_to_dict",
60
+ "fit_synthesizer",
61
+ "generate_from_fitted",
62
+ "generate_from_spec",
63
+ "load_fitted_synthesizer",
64
+ "load_generation_spec",
65
+ "save_evaluation_report",
66
+ "save_fitted_synthesizer",
67
+ ]
@@ -0,0 +1 @@
1
+ """Pure scientific kernels for synthetic data generation."""
@@ -0,0 +1,269 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+ import numpy as np
6
+ import polars as pl
7
+ from scipy.stats import ks_2samp
8
+
9
+
10
+ def null_rate(series: pl.Series) -> float:
11
+ """Return the fraction of null rows in a series."""
12
+ if len(series) == 0:
13
+ return 0.0
14
+ return series.null_count() / len(series)
15
+
16
+
17
+ def null_rate_delta(source: pl.Series, synthetic: pl.Series) -> float:
18
+ """Return synthetic null-rate minus source null-rate."""
19
+ return null_rate(synthetic) - null_rate(source)
20
+
21
+
22
+ def categorical_total_variation_distance(
23
+ source: pl.Series,
24
+ synthetic: pl.Series,
25
+ ) -> float:
26
+ """Compare categorical distributions with total-variation distance."""
27
+ source_values = _string_values(source)
28
+ synthetic_values = _string_values(synthetic)
29
+ if source_values.size == 0 and synthetic_values.size == 0:
30
+ return 0.0
31
+ if source_values.size == 0 or synthetic_values.size == 0:
32
+ return 1.0
33
+
34
+ source_freq = _category_proportions(source_values)
35
+ synthetic_freq = _category_proportions(synthetic_values)
36
+ categories = set(source_freq) | set(synthetic_freq)
37
+ distance = sum(
38
+ abs(synthetic_freq.get(category, 0.0) - source_freq.get(category, 0.0))
39
+ for category in categories
40
+ )
41
+ return 0.5 * distance
42
+
43
+
44
+ def unseen_synthetic_category_rate(
45
+ source: pl.Series,
46
+ synthetic: pl.Series,
47
+ ) -> float:
48
+ """Return the share of synthetic non-null values unseen in source data."""
49
+ source_values = _string_values(source)
50
+ synthetic_values = _string_values(synthetic)
51
+ if synthetic_values.size == 0:
52
+ return 0.0
53
+ if source_values.size == 0:
54
+ return 1.0
55
+
56
+ observed = set(source_values.tolist())
57
+ unseen_count = sum(value not in observed for value in synthetic_values.tolist())
58
+ return unseen_count / synthetic_values.size
59
+
60
+
61
+ def numeric_summary_deltas(
62
+ source: pl.Series,
63
+ synthetic: pl.Series,
64
+ ) -> dict[str, float | None]:
65
+ """Return numeric summary statistics and synthetic-minus-source deltas."""
66
+ source_summary = _numeric_summary(_finite_numeric_values(source))
67
+ synthetic_summary = _numeric_summary(_finite_numeric_values(synthetic))
68
+ return {
69
+ "source_mean": source_summary["mean"],
70
+ "synthetic_mean": synthetic_summary["mean"],
71
+ "mean_delta": _optional_delta(
72
+ source_summary["mean"], synthetic_summary["mean"]
73
+ ),
74
+ "source_std": source_summary["std"],
75
+ "synthetic_std": synthetic_summary["std"],
76
+ "std_delta": _optional_delta(source_summary["std"], synthetic_summary["std"]),
77
+ "source_min": source_summary["min"],
78
+ "synthetic_min": synthetic_summary["min"],
79
+ "min_delta": _optional_delta(source_summary["min"], synthetic_summary["min"]),
80
+ "source_max": source_summary["max"],
81
+ "synthetic_max": synthetic_summary["max"],
82
+ "max_delta": _optional_delta(source_summary["max"], synthetic_summary["max"]),
83
+ }
84
+
85
+
86
+ def numeric_ks_statistic(source: pl.Series, synthetic: pl.Series) -> float | None:
87
+ """Return a two-sample Kolmogorov-Smirnov statistic for numeric values."""
88
+ source_values = _finite_numeric_values(source)
89
+ synthetic_values = _finite_numeric_values(synthetic)
90
+ if source_values.size == 0 or synthetic_values.size == 0:
91
+ return None
92
+ return float(ks_2samp(source_values, synthetic_values).statistic)
93
+
94
+
95
+ def exact_row_match_rate(source_df: pl.DataFrame, synthetic_df: pl.DataFrame) -> float:
96
+ """Return the fraction of synthetic rows exactly present in the source data."""
97
+ if synthetic_df.height == 0:
98
+ return 0.0
99
+ _validate_matching_columns(source_df, synthetic_df)
100
+ if source_df.height == 0 or source_df.width == 0:
101
+ return 0.0
102
+
103
+ source_rows = set(source_df.iter_rows())
104
+ matched = sum(row in source_rows for row in synthetic_df.iter_rows())
105
+ return matched / synthetic_df.height
106
+
107
+
108
+ def duplicate_row_rate(df: pl.DataFrame) -> float:
109
+ """Return the fraction of rows beyond the unique-row count."""
110
+ if df.height == 0:
111
+ return 0.0
112
+ return (df.height - df.unique().height) / df.height
113
+
114
+
115
+ def nearest_neighbor_distance_summary(
116
+ source_df: pl.DataFrame,
117
+ synthetic_df: pl.DataFrame,
118
+ columns: Iterable[str],
119
+ *,
120
+ max_pairwise_rows: int = 1000,
121
+ random_seed: int = 343,
122
+ ) -> dict[str, float | int | None]:
123
+ """Return synthetic-to-source nearest-neighbor distances over numeric columns."""
124
+ if max_pairwise_rows <= 0:
125
+ raise ValueError("max_pairwise_rows must be positive")
126
+
127
+ resolved_columns = tuple(columns)
128
+ source_matrix = _numeric_matrix(source_df, resolved_columns)
129
+ synthetic_matrix = _numeric_matrix(synthetic_df, resolved_columns)
130
+ if (
131
+ not resolved_columns
132
+ or source_matrix.shape[0] == 0
133
+ or synthetic_matrix.shape[0] == 0
134
+ ):
135
+ return _empty_nearest_neighbor_summary(
136
+ source_matrix.shape[0],
137
+ synthetic_matrix.shape[0],
138
+ len(resolved_columns),
139
+ )
140
+
141
+ rng = np.random.default_rng(random_seed)
142
+ source_sample = _sample_rows(source_matrix, max_pairwise_rows, rng)
143
+ synthetic_sample = _sample_rows(synthetic_matrix, max_pairwise_rows, rng)
144
+ scaled_source, scaled_synthetic = _scale_against_source(
145
+ source_sample,
146
+ synthetic_sample,
147
+ )
148
+ nearest = _nearest_distances(scaled_source, scaled_synthetic)
149
+
150
+ return {
151
+ "source_rows_compared": int(source_sample.shape[0]),
152
+ "synthetic_rows_compared": int(synthetic_sample.shape[0]),
153
+ "column_count": len(resolved_columns),
154
+ "min": float(np.min(nearest)),
155
+ "p50": float(np.percentile(nearest, 50)),
156
+ "p95": float(np.percentile(nearest, 95)),
157
+ "mean": float(np.mean(nearest)),
158
+ "max": float(np.max(nearest)),
159
+ }
160
+
161
+
162
+ def _string_values(series: pl.Series) -> np.ndarray:
163
+ return series.cast(pl.String, strict=False).drop_nulls().to_numpy()
164
+
165
+
166
+ def _category_proportions(values: np.ndarray) -> dict[str, float]:
167
+ unique_values, counts = np.unique(values.astype(str), return_counts=True)
168
+ total = float(values.size)
169
+ return {
170
+ str(value): float(count) / total
171
+ for value, count in zip(unique_values, counts, strict=True)
172
+ }
173
+
174
+
175
+ def _finite_numeric_values(series: pl.Series) -> np.ndarray:
176
+ values = series.cast(pl.Float64, strict=False).drop_nulls().to_numpy()
177
+ numeric = np.asarray(values, dtype=float)
178
+ return numeric[np.isfinite(numeric)]
179
+
180
+
181
+ def _numeric_summary(values: np.ndarray) -> dict[str, float | None]:
182
+ if values.size == 0:
183
+ return {"mean": None, "std": None, "min": None, "max": None}
184
+ return {
185
+ "mean": float(np.mean(values)),
186
+ "std": float(np.std(values)),
187
+ "min": float(np.min(values)),
188
+ "max": float(np.max(values)),
189
+ }
190
+
191
+
192
+ def _optional_delta(
193
+ source_value: float | None,
194
+ synthetic_value: float | None,
195
+ ) -> float | None:
196
+ if source_value is None or synthetic_value is None:
197
+ return None
198
+ return synthetic_value - source_value
199
+
200
+
201
+ def _validate_matching_columns(
202
+ source_df: pl.DataFrame,
203
+ synthetic_df: pl.DataFrame,
204
+ ) -> None:
205
+ if source_df.columns != synthetic_df.columns:
206
+ raise ValueError("source_df and synthetic_df must have matching columns")
207
+
208
+
209
+ def _numeric_matrix(df: pl.DataFrame, columns: tuple[str, ...]) -> np.ndarray:
210
+ if not columns or df.height == 0:
211
+ return np.empty((0, len(columns)), dtype=float)
212
+ numeric_df = df.select(
213
+ pl.col(column).cast(pl.Float64, strict=False).alias(column)
214
+ for column in columns
215
+ )
216
+ matrix = np.asarray(numeric_df.to_numpy(), dtype=float)
217
+ if matrix.size == 0:
218
+ return np.empty((0, len(columns)), dtype=float)
219
+ finite_rows = np.isfinite(matrix).all(axis=1)
220
+ return matrix[finite_rows]
221
+
222
+
223
+ def _empty_nearest_neighbor_summary(
224
+ source_rows: int,
225
+ synthetic_rows: int,
226
+ column_count: int,
227
+ ) -> dict[str, float | int | None]:
228
+ return {
229
+ "source_rows_compared": source_rows,
230
+ "synthetic_rows_compared": synthetic_rows,
231
+ "column_count": column_count,
232
+ "min": None,
233
+ "p50": None,
234
+ "p95": None,
235
+ "mean": None,
236
+ "max": None,
237
+ }
238
+
239
+
240
+ def _sample_rows(
241
+ matrix: np.ndarray,
242
+ max_rows: int,
243
+ rng: np.random.Generator,
244
+ ) -> np.ndarray:
245
+ if matrix.shape[0] <= max_rows:
246
+ return matrix
247
+ indices = rng.choice(matrix.shape[0], size=max_rows, replace=False)
248
+ return matrix[np.sort(indices)]
249
+
250
+
251
+ def _scale_against_source(
252
+ source_matrix: np.ndarray,
253
+ synthetic_matrix: np.ndarray,
254
+ ) -> tuple[np.ndarray, np.ndarray]:
255
+ center = np.mean(source_matrix, axis=0)
256
+ scale = np.std(source_matrix, axis=0)
257
+ scale = np.where(scale > 0, scale, 1.0)
258
+ return (source_matrix - center) / scale, (synthetic_matrix - center) / scale
259
+
260
+
261
+ def _nearest_distances(
262
+ source_matrix: np.ndarray,
263
+ synthetic_matrix: np.ndarray,
264
+ ) -> np.ndarray:
265
+ distances = np.empty(synthetic_matrix.shape[0], dtype=float)
266
+ for index, synthetic_row in enumerate(synthetic_matrix):
267
+ row_distances = np.linalg.norm(source_matrix - synthetic_row, axis=1)
268
+ distances[index] = float(np.min(row_distances))
269
+ return distances
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+
6
+ def sigmoid(x: np.ndarray) -> np.ndarray:
7
+ """Apply sigmoid after clipping extreme log-odds for numerical stability."""
8
+ return 1 / (1 + np.exp(-np.clip(x, -500, 500)))
9
+
10
+
11
+ def logit(p: float | np.ndarray, epsilon: float = 1e-6):
12
+ """Safely compute logit values for probabilities."""
13
+ p_clipped = np.clip(p, epsilon, 1 - epsilon)
14
+ logit_p = np.log(p_clipped / (1 - p_clipped))
15
+ return logit_p
16
+
17
+
18
+ def derive_child_seed(base_seed: int, stream_id: int) -> int:
19
+ """Derive an independent child seed for a specific RNG stream."""
20
+ ss = np.random.SeedSequence([base_seed, stream_id])
21
+ return int(ss.generate_state(1, dtype=np.uint32)[0])
@@ -0,0 +1,268 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping
4
+
5
+ import numpy as np
6
+ import polars as pl
7
+ from scipy.stats import truncnorm
8
+
9
+ from tabdat_synth.core.probability import logit, sigmoid
10
+ from tabdat_synth.core.statistics import compute_category_frequencies
11
+ from tabdat_synth.domain.contracts import FreqShift
12
+
13
+ MissingCategoryHandler = Callable[[str], None]
14
+ MISSING_CATEGORY_LABEL = "__MISSING__"
15
+ _AGE_GROUP_BREAKS = [54, 64, 74, 84]
16
+ _AGE_GROUP_LABELS = ["1", "2", "3", "4", "5"]
17
+ _AGE_GROUP_TO_CONTINUOUS = {"1": 57, "2": 67, "3": 77, "4": 87, "5": 92}
18
+ _UNKNOWN_AGE_GROUP_FALLBACK = 57
19
+
20
+
21
+ def estimate_empirical_distribution(
22
+ series: pl.Series,
23
+ ) -> tuple[list[str | int | float], list[float]]:
24
+ """Estimate a discrete distribution from a prepared source series."""
25
+ values = series.to_list()
26
+ if not values:
27
+ raise ValueError(f"cannot sample from empty series '{series.name}'")
28
+
29
+ counts: dict[str | int | float, int] = {}
30
+ for value in values:
31
+ if value is None:
32
+ raise ValueError(f"series '{series.name}' contains null values")
33
+ counts[value] = counts.get(value, 0) + 1
34
+
35
+ total = float(len(values))
36
+ return list(counts.keys()), [count / total for count in counts.values()]
37
+
38
+
39
+ def sample_empirical_distribution(
40
+ n_samples: int,
41
+ values: list[str | int | float] | tuple[str | int | float, ...],
42
+ probabilities: list[float] | tuple[float, ...],
43
+ seed: int,
44
+ ) -> np.ndarray:
45
+ """Sample from a persisted discrete empirical distribution."""
46
+ if not values:
47
+ raise ValueError("cannot sample from an empty empirical distribution")
48
+ if len(values) != len(probabilities):
49
+ raise ValueError("empirical distribution values/probabilities length mismatch")
50
+
51
+ rng = np.random.default_rng(seed=seed)
52
+ return rng.choice(np.asarray(values, dtype=object), size=n_samples, p=probabilities)
53
+
54
+
55
+ def sample_empirical_values(
56
+ n_samples: int, series: pl.Series, seed: int
57
+ ) -> np.ndarray:
58
+ """Sample observed values with replacement from a prepared source series."""
59
+ values, probabilities = estimate_empirical_distribution(series)
60
+ return sample_empirical_distribution(n_samples, values, probabilities, seed)
61
+
62
+
63
+ def summarize_truncated_normal_source(
64
+ series: pl.Series,
65
+ minimum: float | None = None,
66
+ maximum: float | None = None,
67
+ ) -> tuple[float, float, float, float]:
68
+ """Return observed moments and effective bounds for a source series."""
69
+ values = series.cast(pl.Float64).to_numpy()
70
+ if values.size == 0:
71
+ raise ValueError(f"cannot sample from empty series '{series.name}'")
72
+
73
+ observed_mean = float(np.mean(values))
74
+ observed_std = float(np.std(values, ddof=1)) if values.size > 1 else 0.0
75
+ lower = minimum if minimum is not None else float(np.min(values))
76
+ upper = maximum if maximum is not None else float(np.max(values))
77
+
78
+ if lower > upper:
79
+ raise ValueError(f"invalid bounds for '{series.name}': {lower} > {upper}")
80
+
81
+ return observed_mean, observed_std, lower, upper
82
+
83
+
84
+ def sample_truncated_normal_from_summary(
85
+ n_samples: int,
86
+ observed_mean: float,
87
+ observed_std: float,
88
+ minimum: float,
89
+ maximum: float,
90
+ seed: int,
91
+ mean_shift: float = 0.0,
92
+ round_to_int: bool = False,
93
+ ) -> np.ndarray:
94
+ """Sample bounded numeric values from persisted source moments."""
95
+ shifted_mean = observed_mean + mean_shift
96
+ if observed_std == 0.0 or np.isnan(observed_std):
97
+ sampled = np.full(n_samples, np.clip(shifted_mean, minimum, maximum))
98
+ else:
99
+ a = (minimum - shifted_mean) / observed_std
100
+ b = (maximum - shifted_mean) / observed_std
101
+ sampled = truncnorm.rvs(
102
+ a,
103
+ b,
104
+ loc=shifted_mean,
105
+ scale=observed_std,
106
+ size=n_samples,
107
+ random_state=seed,
108
+ )
109
+
110
+ if round_to_int:
111
+ sampled = np.rint(sampled).astype(int)
112
+
113
+ return sampled
114
+
115
+
116
+ def sample_truncated_normal_values(
117
+ n_samples: int,
118
+ series: pl.Series,
119
+ seed: int,
120
+ minimum: float | None = None,
121
+ maximum: float | None = None,
122
+ mean_shift: float = 0.0,
123
+ round_to_int: bool = False,
124
+ ) -> np.ndarray:
125
+ """Sample bounded numeric values using observed moments from a source series."""
126
+ observed_mean, observed_std, lower, upper = summarize_truncated_normal_source(
127
+ series,
128
+ minimum=minimum,
129
+ maximum=maximum,
130
+ )
131
+ return sample_truncated_normal_from_summary(
132
+ n_samples,
133
+ observed_mean,
134
+ observed_std,
135
+ lower,
136
+ upper,
137
+ seed,
138
+ mean_shift=mean_shift,
139
+ round_to_int=round_to_int,
140
+ )
141
+
142
+
143
+ def apply_freq_shifts(
144
+ original_freqs: Mapping[str, float],
145
+ shifts: tuple[FreqShift, ...],
146
+ missing_category_handler: MissingCategoryHandler | None = None,
147
+ ) -> dict[str, float]:
148
+ """Apply category frequency shifts in percentage points."""
149
+ if not shifts:
150
+ return dict(original_freqs)
151
+
152
+ shifted_freqs = dict(original_freqs)
153
+
154
+ for shift in shifts:
155
+ if shift.source not in shifted_freqs:
156
+ if missing_category_handler:
157
+ missing_category_handler(
158
+ f"source category '{shift.source}' not found in distribution"
159
+ )
160
+ continue
161
+ if shift.dest not in shifted_freqs:
162
+ if missing_category_handler:
163
+ missing_category_handler(
164
+ f"destination category '{shift.dest}' not found in distribution"
165
+ )
166
+ continue
167
+
168
+ shift_amount = shift.shift_pct / 100.0
169
+ shifted_freqs[shift.source] -= shift_amount
170
+ shifted_freqs[shift.dest] += shift_amount
171
+
172
+ if any(freq < 0 for freq in shifted_freqs.values()):
173
+ negative_cats = [cat for cat, freq in shifted_freqs.items() if freq < 0]
174
+ raise ValueError(f"negative frequencies after shifts: {negative_cats}")
175
+
176
+ total = sum(shifted_freqs.values())
177
+ return {cat: freq / total for cat, freq in shifted_freqs.items()}
178
+
179
+
180
+ def generate_continuous_age(
181
+ n_samples: int, original_df: pl.DataFrame, age_drift: float, seed: int
182
+ ) -> pl.DataFrame:
183
+ """Generate continuous age using a truncated normal distribution with drift."""
184
+ age_stats = original_df.select(
185
+ [
186
+ pl.col("age_cont").mean().alias("mean"),
187
+ pl.col("age_cont").std().alias("std"),
188
+ pl.col("age_cont").min().alias("min"),
189
+ pl.col("age_cont").max().alias("max"),
190
+ ]
191
+ ).row(0)
192
+
193
+ age_mean, age_std, age_min, age_max = age_stats
194
+ age_mean += age_drift
195
+
196
+ a = (age_min - age_mean) / age_std
197
+ b = (age_max - age_mean) / age_std
198
+
199
+ ages = truncnorm.rvs(
200
+ a, b, loc=age_mean, scale=age_std, size=n_samples, random_state=seed
201
+ )
202
+
203
+ return pl.DataFrame({"age_cont": ages.round().astype(int)}).with_columns(
204
+ [
205
+ pl.col("age_cont").cast(pl.Int8),
206
+ pl.col("age_cont")
207
+ .cut(breaks=_AGE_GROUP_BREAKS, labels=_AGE_GROUP_LABELS)
208
+ .alias("agegrp"),
209
+ ]
210
+ )
211
+
212
+
213
+ def generate_categorical_start(
214
+ n_samples: int,
215
+ original_df: pl.DataFrame,
216
+ feature_name: str,
217
+ freq_shifts: tuple[FreqShift, ...],
218
+ seed: int,
219
+ missing_category_handler: MissingCategoryHandler | None = None,
220
+ ) -> pl.DataFrame:
221
+ """Generate a categorical starting feature with optional frequency shifts."""
222
+ rng = np.random.default_rng(seed=seed)
223
+
224
+ original_freqs = compute_category_frequencies(original_df[feature_name])
225
+ shifted_freqs = apply_freq_shifts(
226
+ original_freqs, freq_shifts, missing_category_handler
227
+ )
228
+
229
+ categories = list(shifted_freqs.keys())
230
+ probabilities = list(shifted_freqs.values())
231
+ sampled_values = rng.choice(categories, size=n_samples, p=probabilities)
232
+
233
+ df = pl.DataFrame({feature_name: sampled_values}).with_columns(
234
+ pl.col(feature_name).cast(pl.String).cast(pl.Categorical)
235
+ )
236
+
237
+ if feature_name == "agegrp":
238
+ df = df.with_columns(
239
+ pl.col("agegrp")
240
+ .map_elements(
241
+ lambda x: _AGE_GROUP_TO_CONTINUOUS.get(x, _UNKNOWN_AGE_GROUP_FALLBACK),
242
+ return_dtype=pl.Int8,
243
+ )
244
+ .alias("age_cont")
245
+ )
246
+
247
+ return df
248
+
249
+
250
+ def generate_responses(
251
+ log_odds: np.ndarray, intercept: float, slope: float, seed: int
252
+ ) -> tuple[np.ndarray, np.ndarray]:
253
+ """Generate risk probabilities and binary outcomes from log-odds."""
254
+ rng = np.random.default_rng(seed=seed)
255
+
256
+ risk_probs = sigmoid(log_odds)
257
+ mean_risk_proba = risk_probs.mean()
258
+ mean_risk_log_odds = logit(mean_risk_proba)
259
+
260
+ response_log_odds = np.where(
261
+ log_odds >= mean_risk_log_odds,
262
+ intercept + slope * (log_odds - mean_risk_log_odds) + mean_risk_log_odds,
263
+ intercept + log_odds,
264
+ )
265
+ response_probs = sigmoid(response_log_odds)
266
+ outcomes = rng.binomial(n=1, p=response_probs)
267
+
268
+ return risk_probs, outcomes