MESSY-synth 0.0.1__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.
- MESSY_synth/__init__.py +58 -0
- MESSY_synth/__main__.py +204 -0
- MESSY_synth/constraints.py +351 -0
- MESSY_synth/generate.py +585 -0
- MESSY_synth/inference.py +925 -0
- MESSY_synth/plan.py +774 -0
- MESSY_synth/regexgen.py +400 -0
- MESSY_synth/smoke.py +342 -0
- MESSY_synth/synth.py +178 -0
- MESSY_synth/validate.py +429 -0
- MESSY_synth/values.py +421 -0
- MESSY_synth/writer.py +280 -0
- messy_synth-0.0.1.dist-info/METADATA +250 -0
- messy_synth-0.0.1.dist-info/RECORD +18 -0
- messy_synth-0.0.1.dist-info/WHEEL +5 -0
- messy_synth-0.0.1.dist-info/entry_points.txt +2 -0
- messy_synth-0.0.1.dist-info/licenses/LICENSE +21 -0
- messy_synth-0.0.1.dist-info/top_level.txt +1 -0
MESSY_synth/generate.py
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
"""Materialize a :class:`~MESSY_synth.plan.DatasetPlan` into in-memory polars frames.
|
|
2
|
+
|
|
3
|
+
This is where the plan's abstract promises become rows. The three that matter, and how they are
|
|
4
|
+
kept:
|
|
5
|
+
|
|
6
|
+
- **One subject universe.** Every column in the ``__subjects__`` pool is filled from the same list
|
|
7
|
+
of integer ids, so events extracted from different files land on the same subjects.
|
|
8
|
+
- **Joins that land.** A column marked ``covers_pool`` *enumerates* its pool positionally rather
|
|
9
|
+
than sampling it, guaranteeing every referenced key exists on the far side. Everything else
|
|
10
|
+
samples, so the data still looks like a many-to-one relationship rather than a lockstep zip.
|
|
11
|
+
- **Coherent time.** Each subject gets a :class:`~MESSY_synth.values.SubjectTimeline` up front, and
|
|
12
|
+
every timestamp for that subject is drawn from inside it — births before the window, deaths
|
|
13
|
+
after, ordinary events within.
|
|
14
|
+
|
|
15
|
+
Generation is deterministic: each column seeds its own RNG from ``(seed, prefix, column)``, so the
|
|
16
|
+
same config and seed always produce byte-identical output, and adding a table to a config does not
|
|
17
|
+
perturb the values in unrelated tables.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import itertools
|
|
23
|
+
import logging
|
|
24
|
+
import random
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
import polars as pl
|
|
29
|
+
|
|
30
|
+
from .constraints import ColumnConstraint, ValueKind
|
|
31
|
+
from .plan import (
|
|
32
|
+
DEFAULT_ROWS_PER_SUBJECT,
|
|
33
|
+
DEFAULT_VOCAB_SIZE,
|
|
34
|
+
MAX_METADATA_ROWS,
|
|
35
|
+
SUBJECT_KEY_POOL,
|
|
36
|
+
SUBJECT_POOL,
|
|
37
|
+
build_plan,
|
|
38
|
+
)
|
|
39
|
+
from .values import ValueFactory, build_timeline, categorical_pool, format_datetime
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
42
|
+
from MEDS_extract.config import MessyConfig
|
|
43
|
+
|
|
44
|
+
from .plan import ColumnPlan, DatasetPlan, TablePlan
|
|
45
|
+
from .values import SubjectTimeline
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
#: Share of rows in a numeric column that take one of the config's compared-against literals.
|
|
50
|
+
REQUIRED_VALUE_SHARE = 0.35
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class GenerationOptions:
|
|
55
|
+
"""Knobs controlling the size and shape of the generated dataset.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
seed: Master seed. Output is a pure function of (config, options), so a given seed
|
|
59
|
+
reproduces a dataset exactly.
|
|
60
|
+
n_subjects: How many distinct subjects to invent.
|
|
61
|
+
rows_per_subject: Rows per subject in each timed event table.
|
|
62
|
+
vocab_size: Distinct values in each categorical vocabulary.
|
|
63
|
+
null_fraction: Fraction of nullable values emitted as nulls, so the config's ``??``
|
|
64
|
+
coalescing and null-drop paths are actually exercised.
|
|
65
|
+
death_probability: Fraction of subjects given a death timestamp.
|
|
66
|
+
numeric_ranges: Per-column-name numeric bounds overriding the default ``(1, 100)``.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
seed: int = 0
|
|
70
|
+
n_subjects: int = 20
|
|
71
|
+
rows_per_subject: int = DEFAULT_ROWS_PER_SUBJECT
|
|
72
|
+
vocab_size: int = DEFAULT_VOCAB_SIZE
|
|
73
|
+
null_fraction: float = 0.05
|
|
74
|
+
death_probability: float = 0.3
|
|
75
|
+
numeric_ranges: dict[str, tuple[float, float]] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class GeneratedDataset:
|
|
80
|
+
"""The generated frames plus the plan that produced them.
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
plan: The plan this dataset was generated from, retained so callers can explain or verify
|
|
84
|
+
the output without recomputing it.
|
|
85
|
+
frames: One frame per source file, keyed by table prefix.
|
|
86
|
+
timelines: The invented per-subject timelines, keyed by subject id.
|
|
87
|
+
truncated_metadata: Metadata prefixes whose key combinations exceeded
|
|
88
|
+
:data:`~MESSY_synth.plan.MAX_METADATA_ROWS`, mapped to how many were dropped. Codes
|
|
89
|
+
built from a dropped combination have no dictionary row, so this is surfaced as a
|
|
90
|
+
finding rather than left to a log line.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
plan: DatasetPlan
|
|
94
|
+
frames: dict[str, pl.DataFrame]
|
|
95
|
+
timelines: dict[int, SubjectTimeline]
|
|
96
|
+
truncated_metadata: dict[str, int] = field(default_factory=dict)
|
|
97
|
+
|
|
98
|
+
def summary(self) -> pl.DataFrame:
|
|
99
|
+
"""Return a one-row-per-file overview of what was generated.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
A frame with ``prefix``, ``rows``, ``columns``, and ``kind`` columns.
|
|
103
|
+
"""
|
|
104
|
+
return pl.DataFrame(
|
|
105
|
+
{
|
|
106
|
+
"prefix": [t.prefix for t in self.plan.tables],
|
|
107
|
+
"rows": [self.frames[t.prefix].height for t in self.plan.tables],
|
|
108
|
+
"columns": [self.frames[t.prefix].width for t in self.plan.tables],
|
|
109
|
+
"kind": [t.kind for t in self.plan.tables],
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def generate(cfg: MessyConfig, options: GenerationOptions | None = None) -> GeneratedDataset:
|
|
115
|
+
"""Generate a complete synthetic source dataset for one MESSY config.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
cfg: The parsed MESSY config.
|
|
119
|
+
options: Size and shape knobs; defaults to :class:`GenerationOptions`.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The :class:`GeneratedDataset`.
|
|
123
|
+
|
|
124
|
+
Examples:
|
|
125
|
+
>>> from MEDS_extract.config import MessyConfig
|
|
126
|
+
>>> cfg = MessyConfig.parse({
|
|
127
|
+
... "etl": {"dataset_name": "Demo"},
|
|
128
|
+
... "_defaults": {"subject_id": "$patient_id"},
|
|
129
|
+
... "patients": {
|
|
130
|
+
... "dob": {"code": "MEDS_BIRTH", "time": '$dob::"%Y-%m-%d"'},
|
|
131
|
+
... "sex": {"code": 'f"SEX//{$sex}"', "time": None},
|
|
132
|
+
... },
|
|
133
|
+
... "labs": {
|
|
134
|
+
... "lab": {
|
|
135
|
+
... "code": 'f"LAB//{$itemid}"',
|
|
136
|
+
... "time": '$charttime::"%Y-%m-%d %H:%M:%S"',
|
|
137
|
+
... "numeric_value": "$value",
|
|
138
|
+
... "_metadata": {"d_labitems": {"itemid": "$itemid", "description": "$label"}},
|
|
139
|
+
... },
|
|
140
|
+
... },
|
|
141
|
+
... })
|
|
142
|
+
>>> ds = generate(cfg, GenerationOptions(seed=0, n_subjects=4, rows_per_subject=2))
|
|
143
|
+
>>> ds.summary()
|
|
144
|
+
shape: (3, 4)
|
|
145
|
+
┌────────────┬──────┬─────────┬───────────────┐
|
|
146
|
+
│ prefix ┆ rows ┆ columns ┆ kind │
|
|
147
|
+
│ --- ┆ --- ┆ --- ┆ --- │
|
|
148
|
+
│ str ┆ i64 ┆ i64 ┆ str │
|
|
149
|
+
╞════════════╪══════╪═════════╪═══════════════╡
|
|
150
|
+
│ d_labitems ┆ 8 ┆ 2 ┆ metadata │
|
|
151
|
+
│ labs ┆ 8 ┆ 4 ┆ events │
|
|
152
|
+
│ patients ┆ 4 ┆ 3 ┆ subject-level │
|
|
153
|
+
└────────────┴──────┴─────────┴───────────────┘
|
|
154
|
+
|
|
155
|
+
``patients`` carries a static event, so it is one row per subject; the birth column is
|
|
156
|
+
rendered in exactly the format the config parses it with, and the occasional null in a
|
|
157
|
+
code component is deliberate — in 0.7 a null component drops the row, and that path
|
|
158
|
+
deserves to be exercised too:
|
|
159
|
+
|
|
160
|
+
>>> ds.frames["patients"].sort("patient_id")
|
|
161
|
+
shape: (4, 3)
|
|
162
|
+
┌────────────┬────────────┬───────────────┐
|
|
163
|
+
│ patient_id ┆ dob ┆ sex │
|
|
164
|
+
│ --- ┆ --- ┆ --- │
|
|
165
|
+
│ i64 ┆ str ┆ str │
|
|
166
|
+
╞════════════╪════════════╪═══════════════╡
|
|
167
|
+
│ 1 ┆ 1969-09-28 ┆ null │
|
|
168
|
+
│ 2 ┆ 1959-10-06 ┆ SYNTH_SEX_004 │
|
|
169
|
+
│ 3 ┆ 1940-10-26 ┆ SYNTH_SEX_001 │
|
|
170
|
+
│ 4 ┆ 1951-11-27 ┆ SYNTH_SEX_003 │
|
|
171
|
+
└────────────┴────────────┴───────────────┘
|
|
172
|
+
|
|
173
|
+
Every value is visibly synthetic; nothing here resembles a real vocabulary.
|
|
174
|
+
|
|
175
|
+
The metadata dictionary covers the event table's vocabulary exactly, which is what makes
|
|
176
|
+
the code-description join land instead of silently matching nothing:
|
|
177
|
+
|
|
178
|
+
>>> set(ds.frames["labs"]["itemid"]) <= set(ds.frames["d_labitems"]["itemid"])
|
|
179
|
+
True
|
|
180
|
+
|
|
181
|
+
Generation is deterministic given a seed:
|
|
182
|
+
|
|
183
|
+
>>> other = generate(cfg, GenerationOptions(seed=0, n_subjects=4, rows_per_subject=2))
|
|
184
|
+
>>> other.frames["labs"].equals(ds.frames["labs"])
|
|
185
|
+
True
|
|
186
|
+
"""
|
|
187
|
+
options = options or GenerationOptions()
|
|
188
|
+
plan = build_plan(
|
|
189
|
+
cfg,
|
|
190
|
+
n_subjects=options.n_subjects,
|
|
191
|
+
rows_per_subject=options.rows_per_subject,
|
|
192
|
+
vocab_size=options.vocab_size,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
pools = _materialize_pools(plan, options)
|
|
196
|
+
subject_ids: list[int] = pools[SUBJECT_POOL]
|
|
197
|
+
timelines = {
|
|
198
|
+
sid: build_timeline(sid, random.Random(f"{options.seed}:timeline:{sid}"), options.death_probability)
|
|
199
|
+
for sid in subject_ids
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
joins = {t.input_prefix: t.join for t in cfg.event_tables if t.join is not None}
|
|
203
|
+
frames: dict[str, pl.DataFrame] = {}
|
|
204
|
+
truncated: dict[str, int] = {}
|
|
205
|
+
for table in _generation_order(plan, joins):
|
|
206
|
+
frame = _generate_table(table, plan, pools, timelines, subject_ids, options, truncated)
|
|
207
|
+
join = joins.get(table.prefix)
|
|
208
|
+
if join is not None and join.input_prefix in frames:
|
|
209
|
+
frame = _adopt_join_keys(frame, table, join, frames[join.input_prefix], options)
|
|
210
|
+
frames[table.prefix] = frame
|
|
211
|
+
return GeneratedDataset(plan=plan, frames=frames, timelines=timelines, truncated_metadata=truncated)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _generation_order(plan: DatasetPlan, joins: dict) -> list[TablePlan]:
|
|
215
|
+
"""Order tables so every join target is built before the table that joins to it.
|
|
216
|
+
|
|
217
|
+
Join keys are not invented independently on the two sides — the referencing table copies real
|
|
218
|
+
key tuples out of the target's finished frame — so the target has to exist first.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
plan: The dataset plan.
|
|
222
|
+
joins: Table prefix to its :class:`~MEDS_extract.config.JoinConfig`.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
The table plans in dependency order. A cycle (which MEDS-Extract rejects for self-joins,
|
|
226
|
+
but could in principle span tables) degrades to the plan's own order rather than hanging.
|
|
227
|
+
"""
|
|
228
|
+
by_prefix = {t.prefix: t for t in plan.tables}
|
|
229
|
+
ordered: list[TablePlan] = []
|
|
230
|
+
placed: set[str] = set()
|
|
231
|
+
|
|
232
|
+
def visit(prefix: str, seen: frozenset[str]) -> None:
|
|
233
|
+
if prefix in placed or prefix in seen or prefix not in by_prefix:
|
|
234
|
+
return
|
|
235
|
+
join = joins.get(prefix)
|
|
236
|
+
if join is not None:
|
|
237
|
+
visit(join.input_prefix, seen | {prefix})
|
|
238
|
+
if prefix not in placed:
|
|
239
|
+
placed.add(prefix)
|
|
240
|
+
ordered.append(by_prefix[prefix])
|
|
241
|
+
|
|
242
|
+
for table in plan.tables:
|
|
243
|
+
visit(table.prefix, frozenset())
|
|
244
|
+
return ordered
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _adopt_join_keys(
|
|
248
|
+
frame: pl.DataFrame,
|
|
249
|
+
table: TablePlan,
|
|
250
|
+
join,
|
|
251
|
+
target: pl.DataFrame,
|
|
252
|
+
options: GenerationOptions,
|
|
253
|
+
) -> pl.DataFrame:
|
|
254
|
+
"""Replace a table's join-key columns with real key tuples drawn from the target's frame.
|
|
255
|
+
|
|
256
|
+
Generating the two sides independently is not good enough for a composite key: each column
|
|
257
|
+
might individually come from the right pool while the *combination* exists nowhere on the far
|
|
258
|
+
side, so almost every row dangles and is silently dropped. Copying whole tuples from the target
|
|
259
|
+
makes every left key resolve by construction, for composite and single keys alike.
|
|
260
|
+
|
|
261
|
+
The subject column is exempt. It already agrees across tables by drawing from the shared subject
|
|
262
|
+
universe, and overwriting it would break the round-robin assignment that gives every subject a
|
|
263
|
+
row — and, in a subject-level table, would put two rows on one subject.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
frame: The freshly generated frame.
|
|
267
|
+
table: Its plan.
|
|
268
|
+
join: The table's join config.
|
|
269
|
+
target: The already-generated frame of the join target.
|
|
270
|
+
options: The generation options, for the seed.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
The frame with its join keys adopted from the target.
|
|
274
|
+
"""
|
|
275
|
+
pairs = [
|
|
276
|
+
(left, right)
|
|
277
|
+
for left, right in zip(join.left_on, join.right_on, strict=True)
|
|
278
|
+
if left != table.subject_column and left in frame.columns and right in target.columns
|
|
279
|
+
]
|
|
280
|
+
if not pairs or target.height == 0:
|
|
281
|
+
return frame
|
|
282
|
+
|
|
283
|
+
rng = random.Random(f"{options.seed}:joinkeys:{table.prefix}")
|
|
284
|
+
picks = [rng.randrange(target.height) for _ in range(frame.height)]
|
|
285
|
+
return frame.with_columns(
|
|
286
|
+
*(target[right].gather(picks).alias(left) for left, right in pairs),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _materialize_pools(plan: DatasetPlan, options: GenerationOptions) -> dict[str, list]:
|
|
291
|
+
"""Turn each planned pool into its concrete list of values.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
plan: The dataset plan.
|
|
295
|
+
options: The generation options.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
A mapping of pool id to values.
|
|
299
|
+
"""
|
|
300
|
+
pools: dict[str, list] = {SUBJECT_POOL: list(range(1, options.n_subjects + 1))}
|
|
301
|
+
for pool_id, pool in plan.pools.items():
|
|
302
|
+
if pool_id == SUBJECT_POOL:
|
|
303
|
+
continue
|
|
304
|
+
# Name the pool after its first member column so tokens are self-describing.
|
|
305
|
+
column = pool.members[0][1] if pool.members else pool_id
|
|
306
|
+
pools[pool_id] = categorical_pool(
|
|
307
|
+
column, pool.constraint, pool.size, random.Random(f"{options.seed}:pool:{pool_id}")
|
|
308
|
+
)
|
|
309
|
+
return pools
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _generate_table(
|
|
313
|
+
table: TablePlan,
|
|
314
|
+
plan: DatasetPlan,
|
|
315
|
+
pools: dict[str, list],
|
|
316
|
+
timelines: dict[int, SubjectTimeline],
|
|
317
|
+
subject_ids: list[int],
|
|
318
|
+
options: GenerationOptions,
|
|
319
|
+
truncated: dict[str, int],
|
|
320
|
+
) -> pl.DataFrame:
|
|
321
|
+
"""Generate one file's frame.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
table: The table plan.
|
|
325
|
+
plan: The dataset plan (for pool metadata).
|
|
326
|
+
pools: Materialized pool values.
|
|
327
|
+
timelines: Per-subject timelines.
|
|
328
|
+
subject_ids: The subject universe.
|
|
329
|
+
options: The generation options.
|
|
330
|
+
truncated: Accumulator recording metadata tables whose key product hit the row cap.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
The generated frame.
|
|
334
|
+
"""
|
|
335
|
+
if table.is_metadata:
|
|
336
|
+
return _generate_metadata_table(table, pools, options, truncated)
|
|
337
|
+
|
|
338
|
+
# The plan sizes tables from each pool's *requested* size, but a materialized pool can end up
|
|
339
|
+
# longer — required values, coalesce fallbacks and regex-derived members are all added on top.
|
|
340
|
+
# A covering column must enumerate whatever the pool actually holds, so the row count is raised
|
|
341
|
+
# to match; otherwise the tail of the pool never appears and every row referencing it dangles.
|
|
342
|
+
n_rows = max(
|
|
343
|
+
[table.n_rows, *(len(pools[c.pool_id]) for c in table.columns if c.covers_pool and c.pool_id)]
|
|
344
|
+
)
|
|
345
|
+
# Round-robin rather than random assignment, so every subject is represented in every table
|
|
346
|
+
# instead of a few subjects hogging the rows by chance.
|
|
347
|
+
row_subjects = [subject_ids[i % len(subject_ids)] for i in range(n_rows)] if subject_ids else []
|
|
348
|
+
|
|
349
|
+
data: dict[str, list] = {}
|
|
350
|
+
for column in table.columns:
|
|
351
|
+
data[column.name] = _generate_column(
|
|
352
|
+
column, table, plan, pools, timelines, row_subjects, n_rows, options
|
|
353
|
+
)
|
|
354
|
+
return pl.DataFrame(data, strict=False)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _generate_metadata_table(
|
|
358
|
+
table: TablePlan,
|
|
359
|
+
pools: dict[str, list],
|
|
360
|
+
options: GenerationOptions,
|
|
361
|
+
truncated: dict[str, int],
|
|
362
|
+
) -> pl.DataFrame:
|
|
363
|
+
"""Generate a ``_metadata`` dictionary file.
|
|
364
|
+
|
|
365
|
+
Key columns take the Cartesian product of their pools so that every code the event tables can
|
|
366
|
+
emit has a matching dictionary row. Anything else is descriptive filler.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
table: The table plan.
|
|
370
|
+
pools: Materialized pool values.
|
|
371
|
+
options: The generation options.
|
|
372
|
+
truncated: Accumulator recording how many key combinations were dropped, if any.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
The generated frame.
|
|
376
|
+
"""
|
|
377
|
+
key_columns = [c for c in table.columns if c.covers_pool and c.pool_id]
|
|
378
|
+
other_columns = [c for c in table.columns if c not in key_columns]
|
|
379
|
+
|
|
380
|
+
if key_columns:
|
|
381
|
+
# Every combination, not `table.n_rows` of them: the plan's estimate is based on requested
|
|
382
|
+
# pool sizes, and truncating to it would leave part of the event vocabulary without a
|
|
383
|
+
# dictionary entry — which shows up only as silently null descriptions in codes.parquet.
|
|
384
|
+
combos = list(itertools.product(*[pools[c.pool_id] for c in key_columns]))
|
|
385
|
+
if len(combos) > MAX_METADATA_ROWS:
|
|
386
|
+
# Say so rather than truncating quietly: the dropped combinations become codes with no
|
|
387
|
+
# description, which is indistinguishable from a broken metadata join unless flagged.
|
|
388
|
+
truncated[table.prefix] = len(combos) - MAX_METADATA_ROWS
|
|
389
|
+
logger.warning(
|
|
390
|
+
f"{table.prefix}: {len(combos)} key combinations exceeds the {MAX_METADATA_ROWS}-row "
|
|
391
|
+
f"cap; dropping {len(combos) - MAX_METADATA_ROWS}. Codes built from the dropped "
|
|
392
|
+
f"combinations will have no description. Lower --vocab-size to fit."
|
|
393
|
+
)
|
|
394
|
+
combos = combos[:MAX_METADATA_ROWS]
|
|
395
|
+
else:
|
|
396
|
+
combos = [()] * table.n_rows
|
|
397
|
+
|
|
398
|
+
data: dict[str, list] = {}
|
|
399
|
+
for i, column in enumerate(key_columns):
|
|
400
|
+
data[column.name] = [combo[i] for combo in combos]
|
|
401
|
+
for column in other_columns:
|
|
402
|
+
factory = ValueFactory(
|
|
403
|
+
random.Random(f"{options.seed}:{table.prefix}:{column.name}"),
|
|
404
|
+
null_fraction=0.0,
|
|
405
|
+
numeric_ranges=options.numeric_ranges,
|
|
406
|
+
)
|
|
407
|
+
# Metadata files are read with `infer_schema=False`, so every column is text regardless of
|
|
408
|
+
# what it looks like. Emitting strings here keeps generation honest about that.
|
|
409
|
+
data[column.name] = [factory.text(column.name) for _ in combos]
|
|
410
|
+
return pl.DataFrame(data, strict=False)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _generate_column(
|
|
414
|
+
column: ColumnPlan,
|
|
415
|
+
table: TablePlan,
|
|
416
|
+
plan: DatasetPlan,
|
|
417
|
+
pools: dict[str, list],
|
|
418
|
+
timelines: dict[int, SubjectTimeline],
|
|
419
|
+
row_subjects: list[int],
|
|
420
|
+
n_rows: int,
|
|
421
|
+
options: GenerationOptions,
|
|
422
|
+
) -> list:
|
|
423
|
+
"""Generate the values for one column.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
column: The column plan.
|
|
427
|
+
table: The owning table plan.
|
|
428
|
+
plan: The dataset plan.
|
|
429
|
+
pools: Materialized pool values.
|
|
430
|
+
timelines: Per-subject timelines.
|
|
431
|
+
row_subjects: The subject id assigned to each row.
|
|
432
|
+
n_rows: The number of rows.
|
|
433
|
+
options: The generation options.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
The column's values.
|
|
437
|
+
"""
|
|
438
|
+
del plan
|
|
439
|
+
rng = random.Random(f"{options.seed}:{table.prefix}:{column.name}")
|
|
440
|
+
factory = ValueFactory(rng, null_fraction=options.null_fraction, numeric_ranges=options.numeric_ranges)
|
|
441
|
+
constraint = column.constraint
|
|
442
|
+
kind = constraint.kind
|
|
443
|
+
|
|
444
|
+
if column.pool_id == SUBJECT_POOL:
|
|
445
|
+
return list(row_subjects)
|
|
446
|
+
|
|
447
|
+
if column.pool_id == SUBJECT_KEY_POOL:
|
|
448
|
+
# This column is hashed into the subject id, so it is a function *of the subject*, not a
|
|
449
|
+
# free draw. Indexing by the row's subject keeps one key per subject (sampling would give
|
|
450
|
+
# some subjects two keys and others none) and keeps the key consistent with the timeline
|
|
451
|
+
# already chosen for that subject.
|
|
452
|
+
pool = pools[column.pool_id]
|
|
453
|
+
return [pool[(subject - 1) % len(pool)] for subject in row_subjects]
|
|
454
|
+
|
|
455
|
+
if column.unique_in_table:
|
|
456
|
+
# One distinct value per row: a repeated key on the target side of a plain left join fans
|
|
457
|
+
# the join out, turning one referencing row into many.
|
|
458
|
+
return [factory.token(column.name, i, constraint.min_chars) for i in range(n_rows)]
|
|
459
|
+
|
|
460
|
+
if column.pool_id is not None:
|
|
461
|
+
pool = pools[column.pool_id]
|
|
462
|
+
if column.covers_pool:
|
|
463
|
+
# Enumerate rather than sample: a key value missing from the target table would leave
|
|
464
|
+
# every row referencing it unmatched.
|
|
465
|
+
return [pool[i % len(pool)] for i in range(n_rows)]
|
|
466
|
+
return [factory.maybe_null(rng.choice(pool), constraint.nullable) for _ in range(n_rows)]
|
|
467
|
+
|
|
468
|
+
if kind in (ValueKind.DATETIME, ValueKind.DATE, ValueKind.DATETIME_STR):
|
|
469
|
+
return _generate_temporal(column, timelines, row_subjects, n_rows, factory, rng)
|
|
470
|
+
|
|
471
|
+
if kind in (ValueKind.NUMERIC, ValueKind.INTEGER, ValueKind.IDENTIFIER):
|
|
472
|
+
integral = kind is not ValueKind.NUMERIC
|
|
473
|
+
# A literal the config compares against (`$admissioncount == 1`) has to actually occur, or
|
|
474
|
+
# the branch it gates never fires and the event yields nothing. About a third of rows take
|
|
475
|
+
# a required value, so both sides of the comparison are represented.
|
|
476
|
+
required = [n for n in (_as_number(v, integral) for v in constraint.required_values) if n is not None]
|
|
477
|
+
return [
|
|
478
|
+
factory.maybe_null(
|
|
479
|
+
required[i % len(required)]
|
|
480
|
+
if required and rng.random() < REQUIRED_VALUE_SHARE
|
|
481
|
+
else factory.numeric(column.name, integral=integral, inferred_range=constraint.numeric_range),
|
|
482
|
+
constraint.nullable,
|
|
483
|
+
)
|
|
484
|
+
for i in range(n_rows)
|
|
485
|
+
]
|
|
486
|
+
if kind is ValueKind.BOOLEAN:
|
|
487
|
+
return [factory.maybe_null(rng.choice([True, False]), constraint.nullable) for _ in range(n_rows)]
|
|
488
|
+
if kind is ValueKind.STRING and not _has_shape_evidence(constraint):
|
|
489
|
+
return [factory.maybe_null(factory.text(column.name), constraint.nullable) for _ in range(n_rows)]
|
|
490
|
+
|
|
491
|
+
# Everything else becomes a small token vocabulary. UNKNOWN lands here because a column we
|
|
492
|
+
# could not reason about is safest as an opaque string: it survives CSV inference unchanged and
|
|
493
|
+
# can be interpolated into any code. A STRING column reaches here only when the config pins its
|
|
494
|
+
# shape — a regex it must satisfy, or a literal it is compared against — because free-form
|
|
495
|
+
# placeholder text would fail those.
|
|
496
|
+
vocab = categorical_pool(column.name, constraint, options.vocab_size, rng)
|
|
497
|
+
return [factory.maybe_null(rng.choice(vocab), constraint.nullable) for _ in range(n_rows)]
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _has_shape_evidence(constraint: ColumnConstraint) -> bool:
|
|
501
|
+
"""Return whether the config pins a column's textual shape.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
constraint: The column's constraint.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
True if a regex or a compared-against literal constrains what the text must look like.
|
|
508
|
+
|
|
509
|
+
Examples:
|
|
510
|
+
>>> _has_shape_evidence(ColumnConstraint())
|
|
511
|
+
False
|
|
512
|
+
>>> _has_shape_evidence(ColumnConstraint(extract_patterns=("2003|2010",)))
|
|
513
|
+
True
|
|
514
|
+
"""
|
|
515
|
+
return bool(constraint.extract_patterns or constraint.match_patterns or constraint.required_values)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _as_number(text: str, integral: bool) -> float | int | None:
|
|
519
|
+
"""Parse a recorded literal back into a number, or None if it is not numeric.
|
|
520
|
+
|
|
521
|
+
Required values are recorded as text because most of them are categorical. When the column
|
|
522
|
+
turns out to be numeric they have to be converted back.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
text: The recorded literal.
|
|
526
|
+
integral: Whether an integer is wanted.
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
The number, or None if ``text`` is not numeric.
|
|
530
|
+
|
|
531
|
+
Examples:
|
|
532
|
+
>>> _as_number("1", True), _as_number("1.5", False), _as_number("alive", False)
|
|
533
|
+
(1, 1.5, None)
|
|
534
|
+
"""
|
|
535
|
+
try:
|
|
536
|
+
return int(float(text)) if integral else float(text)
|
|
537
|
+
except (TypeError, ValueError):
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _generate_temporal(
|
|
542
|
+
column: ColumnPlan,
|
|
543
|
+
timelines: dict[int, SubjectTimeline],
|
|
544
|
+
row_subjects: list[int],
|
|
545
|
+
n_rows: int,
|
|
546
|
+
factory: ValueFactory,
|
|
547
|
+
rng: random.Random,
|
|
548
|
+
) -> list:
|
|
549
|
+
"""Generate a timestamp column, honoring birth/death roles and the recorded string formats.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
column: The column plan.
|
|
553
|
+
timelines: Per-subject timelines.
|
|
554
|
+
row_subjects: The subject id assigned to each row.
|
|
555
|
+
n_rows: The number of rows.
|
|
556
|
+
factory: The value factory (for null injection).
|
|
557
|
+
rng: The random source.
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
The column's values — formatted strings when the config parses this column with
|
|
561
|
+
``strptime``, real datetimes otherwise.
|
|
562
|
+
"""
|
|
563
|
+
constraint = column.constraint
|
|
564
|
+
as_string = constraint.kind is ValueKind.DATETIME_STR
|
|
565
|
+
out: list = []
|
|
566
|
+
for i in range(n_rows):
|
|
567
|
+
subject = row_subjects[i] if i < len(row_subjects) else None
|
|
568
|
+
timeline = timelines.get(subject) if subject is not None else None
|
|
569
|
+
if timeline is None:
|
|
570
|
+
timeline = build_timeline(0, random.Random(f"anon:{i}"))
|
|
571
|
+
|
|
572
|
+
role = constraint.effective_temporal_role
|
|
573
|
+
if role == "birth":
|
|
574
|
+
value = timeline.birth
|
|
575
|
+
elif role == "death":
|
|
576
|
+
value = timeline.death
|
|
577
|
+
else:
|
|
578
|
+
value = timeline.sample(rng)
|
|
579
|
+
|
|
580
|
+
if value is None:
|
|
581
|
+
out.append(None)
|
|
582
|
+
continue
|
|
583
|
+
rendered = format_datetime(value, constraint, i) if as_string else value
|
|
584
|
+
out.append(factory.maybe_null(rendered, constraint.effective_nullable))
|
|
585
|
+
return out
|