synthpopcan 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.
Files changed (47) hide show
  1. synthpopcan/__init__.py +33 -0
  2. synthpopcan/api.py +509 -0
  3. synthpopcan/benchmarks.py +179 -0
  4. synthpopcan/calibration.py +201 -0
  5. synthpopcan/cli.py +1216 -0
  6. synthpopcan/cli_ipf.py +351 -0
  7. synthpopcan/cli_microdata.py +489 -0
  8. synthpopcan/cli_output.py +657 -0
  9. synthpopcan/cli_tree.py +2093 -0
  10. synthpopcan/console.py +121 -0
  11. synthpopcan/controls.py +803 -0
  12. synthpopcan/diagnostics.py +320 -0
  13. synthpopcan/ipf.py +368 -0
  14. synthpopcan/localdata.py +120 -0
  15. synthpopcan/microdata.py +1385 -0
  16. synthpopcan/models/__init__.py +266 -0
  17. synthpopcan/models/demo-linked-household-person-package.json +828 -0
  18. synthpopcan/sources.py +86 -0
  19. synthpopcan/statcan.py +526 -0
  20. synthpopcan/tree.py +1672 -0
  21. synthpopcan/tree_benchmark.py +368 -0
  22. synthpopcan/validation.py +303 -0
  23. synthpopcan/web/__init__.py +1 -0
  24. synthpopcan/web/app.mjs +555 -0
  25. synthpopcan/web/browser-job.mjs +27 -0
  26. synthpopcan/web/csv.mjs +87 -0
  27. synthpopcan/web/index.html +339 -0
  28. synthpopcan/web/ipf.mjs +208 -0
  29. synthpopcan/web/preview.mjs +79 -0
  30. synthpopcan/web/result-ui.mjs +151 -0
  31. synthpopcan/web/starter-files.mjs +56 -0
  32. synthpopcan/web/statcan.mjs +149 -0
  33. synthpopcan/web/styles.css +674 -0
  34. synthpopcan/web/synthpopcan-icon-64.png +0 -0
  35. synthpopcan/web/synthpopcan-logo-256.png +0 -0
  36. synthpopcan/web/tree-model.mjs +495 -0
  37. synthpopcan/web/wds-normalize.mjs +171 -0
  38. synthpopcan/web/worker.mjs +122 -0
  39. synthpopcan/web/workflow-views.mjs +127 -0
  40. synthpopcan/web/zip.mjs +99 -0
  41. synthpopcan/web_wds.py +217 -0
  42. synthpopcan/webapp.py +140 -0
  43. synthpopcan-0.1.0.dist-info/METADATA +157 -0
  44. synthpopcan-0.1.0.dist-info/RECORD +47 -0
  45. synthpopcan-0.1.0.dist-info/WHEEL +4 -0
  46. synthpopcan-0.1.0.dist-info/entry_points.txt +2 -0
  47. synthpopcan-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,33 @@
1
+ """Canadian synthetic population tooling.
2
+
3
+ The top-level package intentionally exposes a small beginner-friendly API for
4
+ notebooks and short scripts. Import from modules such as ``synthpopcan.ipf`` or
5
+ ``synthpopcan.tree`` when you need lower-level research and maintainer tools.
6
+ """
7
+
8
+ from synthpopcan.api import (
9
+ LinkedPopulation,
10
+ expand_population,
11
+ fit_ipf,
12
+ generate_from_model,
13
+ read_controls,
14
+ read_model_package,
15
+ read_seed,
16
+ write_population,
17
+ write_weights,
18
+ )
19
+
20
+ __all__ = [
21
+ "LinkedPopulation",
22
+ "__version__",
23
+ "expand_population",
24
+ "fit_ipf",
25
+ "generate_from_model",
26
+ "read_controls",
27
+ "read_model_package",
28
+ "read_seed",
29
+ "write_population",
30
+ "write_weights",
31
+ ]
32
+
33
+ __version__ = "0.1.0"
synthpopcan/api.py ADDED
@@ -0,0 +1,509 @@
1
+ """Beginner-friendly public API for notebooks and short scripts.
2
+
3
+ This module is the small, stable Python surface intended for people who want to
4
+ use SynthPopCan without learning the internal command modules first. It favours
5
+ plain file paths, lists of row dictionaries, and a small number of workflow
6
+ functions that map directly to the two main beginner tasks:
7
+
8
+ * fit seed rows to margin/control totals with IPF;
9
+ * generate linked household/person rows from a prepared model package.
10
+
11
+ Most users should import the top-level package and call these functions from
12
+ there::
13
+
14
+ import synthpopcan as spc
15
+
16
+ controls = spc.read_controls("controls.csv")
17
+ fit = spc.fit_ipf("seed.csv", controls)
18
+ spc.write_weights(fit, "synthetic-weights.csv")
19
+
20
+ package = spc.read_model_package("model-package.json")
21
+ population = spc.generate_from_model(package, households=100)
22
+ spc.write_population(population, "synthetic-population/")
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import csv
28
+ import json
29
+ from collections.abc import Mapping, Sequence
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+
33
+ from synthpopcan.controls import ControlTable, read_control_table
34
+ from synthpopcan.ipf import IPFMargin, IPFResult, Record, expand_records
35
+ from synthpopcan.ipf import fit_ipf as fit_ipf_records
36
+ from synthpopcan.tree import (
37
+ CartTreeModel,
38
+ FrequencyTreeModel,
39
+ generate_linked_population,
40
+ )
41
+
42
+ SeedInput = str | Path | Sequence[Record]
43
+ ControlInput = str | Path | ControlTable | Sequence[IPFMargin]
44
+ ModelPackageInput = str | Path | Mapping[str, object]
45
+ PopulationRows = list[dict[str, str]]
46
+
47
+ __all__ = [
48
+ "LinkedPopulation",
49
+ "PopulationRows",
50
+ "read_seed",
51
+ "read_controls",
52
+ "fit_ipf",
53
+ "expand_population",
54
+ "write_weights",
55
+ "read_model_package",
56
+ "generate_from_model",
57
+ "write_population",
58
+ ]
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class LinkedPopulation:
63
+ """Household and person rows generated from a linked model package.
64
+
65
+ A linked model package generates two related tables: one row per synthetic
66
+ household, and one or more person rows inside those households. This object
67
+ keeps those tables together so that downstream code does not accidentally
68
+ lose the household/person relationship.
69
+
70
+ Parameters
71
+ ----------
72
+ households:
73
+ Synthetic household rows. The exact columns depend on the model package,
74
+ but household identifiers are preserved when the package provides them.
75
+ persons:
76
+ Synthetic person rows. Person rows are generated inside the synthetic
77
+ households and may include household identifiers or household-level
78
+ context columns.
79
+
80
+ Notes
81
+ -----
82
+ Pass a ``LinkedPopulation`` to :func:`write_population` to write a directory
83
+ containing ``households.csv`` and ``persons.csv``.
84
+ """
85
+
86
+ households: PopulationRows
87
+ persons: PopulationRows
88
+
89
+
90
+ def read_seed(path: str | Path) -> PopulationRows:
91
+ """Read a seed CSV as plain row dictionaries.
92
+
93
+ Seed rows are the starting records for IPF. Each row should already contain
94
+ the columns used by the margin/control table, for example ``age`` and
95
+ ``sex`` in a small age/sex example.
96
+
97
+ Parameters
98
+ ----------
99
+ path:
100
+ Path to a CSV file with a header row.
101
+
102
+ Returns
103
+ -------
104
+ list[dict[str, str]]
105
+ One dictionary per CSV row, using the CSV header values as keys.
106
+
107
+ Examples
108
+ --------
109
+ >>> seed = read_seed("seed.csv")
110
+ >>> seed[0]["age"]
111
+ '18-64'
112
+ """
113
+
114
+ with Path(path).open(newline="") as handle:
115
+ return list(csv.DictReader(handle))
116
+
117
+
118
+ def read_controls(path: str | Path) -> ControlTable:
119
+ """Read a normalized SynthPopCan control CSV.
120
+
121
+ A normalized control CSV contains margin totals in the format used by
122
+ SynthPopCan. These files can come from the CLI, the local web app, or a
123
+ hand-prepared CSV.
124
+
125
+ Parameters
126
+ ----------
127
+ path:
128
+ Path to a normalized margin/control CSV.
129
+
130
+ Returns
131
+ -------
132
+ synthpopcan.controls.ControlTable
133
+ A parsed control table that can be passed directly to :func:`fit_ipf`.
134
+
135
+ See Also
136
+ --------
137
+ fit_ipf:
138
+ Fit seed rows to the controls returned by this function.
139
+ """
140
+
141
+ return read_control_table(Path(path))
142
+
143
+
144
+ def fit_ipf(
145
+ seed: SeedInput,
146
+ controls: ControlInput,
147
+ *,
148
+ weight_field: str | None = None,
149
+ max_iterations: int = 100,
150
+ tolerance: float = 1e-6,
151
+ ) -> IPFResult:
152
+ """Fit seed records to controls with iterative proportional fitting.
153
+
154
+ IPF adjusts weights on existing seed rows so that weighted totals match a
155
+ set of margin/control totals. It does not create new categories or new
156
+ variables. Every control dimension should already exist as a column in the
157
+ seed rows.
158
+
159
+ Parameters
160
+ ----------
161
+ seed:
162
+ Either a path to a seed CSV or an in-memory sequence of row mappings.
163
+ Each row should include the dimensions named in the controls.
164
+ controls:
165
+ A path to a normalized control CSV, a
166
+ :class:`synthpopcan.controls.ControlTable`, or a sequence of lower-level
167
+ :class:`synthpopcan.ipf.IPFMargin` objects.
168
+ weight_field:
169
+ Optional seed column containing starting weights. If omitted, every seed
170
+ row starts with weight ``1``.
171
+ max_iterations:
172
+ Maximum number of IPF passes through the controls.
173
+ tolerance:
174
+ Stop once the maximum absolute control error is at or below this value.
175
+
176
+ Returns
177
+ -------
178
+ synthpopcan.ipf.IPFResult
179
+ The fitted records, fitted weights, convergence status, iteration count,
180
+ and final maximum absolute error.
181
+
182
+ Raises
183
+ ------
184
+ ValueError
185
+ Raised when controls refer to missing seed columns or when a control
186
+ cell cannot be represented by the available seed rows.
187
+
188
+ Examples
189
+ --------
190
+ >>> fit = fit_ipf("seed.csv", "controls.csv")
191
+ >>> fit.converged
192
+ True
193
+ >>> write_weights(fit, "synthetic-weights.csv")
194
+ """
195
+
196
+ seed_records = _seed_records(seed)
197
+ margins = _control_margins(controls)
198
+ return fit_ipf_records(
199
+ seed_records,
200
+ margins,
201
+ weight_field=weight_field,
202
+ max_iterations=max_iterations,
203
+ tolerance=tolerance,
204
+ )
205
+
206
+
207
+ def expand_population(result: IPFResult, *, id_field: str = "id") -> PopulationRows:
208
+ """Expand fitted IPF weights into full synthetic rows.
209
+
210
+ Weighted output is usually the practical default for browser and notebook
211
+ work. Expanded rows are useful when another tool expects one row per
212
+ synthetic person, household, or record, but they can become very large.
213
+
214
+ Parameters
215
+ ----------
216
+ result:
217
+ A fitted IPF result from :func:`fit_ipf`.
218
+ id_field:
219
+ Source seed-record identifier column to copy into the ``seed_id`` field
220
+ of expanded rows. Each expanded row also receives a new
221
+ ``synthetic_id``.
222
+
223
+ Returns
224
+ -------
225
+ list[dict[str, str]]
226
+ A full synthetic dataset where each row represents one expanded record.
227
+
228
+ Notes
229
+ -----
230
+ Expansion integerizes fitted weights before repeating records. If the fitted
231
+ weights represent a large population, the returned list can use substantial
232
+ memory.
233
+ """
234
+
235
+ return expand_records(result.records, result.weights, id_field=id_field)
236
+
237
+
238
+ def write_weights(
239
+ result: IPFResult,
240
+ path: str | Path,
241
+ *,
242
+ weight_column: str | None = None,
243
+ ) -> None:
244
+ """Write fitted seed records with a fitted-weight column.
245
+
246
+ This is the recommended way to save IPF output for most workflows. It keeps
247
+ one row per seed record and adds a column containing the fitted synthetic
248
+ population weight.
249
+
250
+ Parameters
251
+ ----------
252
+ result:
253
+ A fitted IPF result from :func:`fit_ipf`.
254
+ path:
255
+ Destination CSV path.
256
+ weight_column:
257
+ Optional output column name for fitted weights. When omitted, the
258
+ function uses ``weight`` unless that column already exists, in which case
259
+ it uses ``fitted_weight``.
260
+
261
+ Raises
262
+ ------
263
+ ValueError
264
+ Raised when the IPF result has no records to write.
265
+ """
266
+
267
+ if not result.records:
268
+ raise ValueError("cannot write weights for an empty IPF result")
269
+ rows = [_string_row(record) for record in result.records]
270
+ output_weight_column = weight_column or _default_weight_column(rows)
271
+ fieldnames = [*rows[0], output_weight_column]
272
+ with Path(path).open("w", newline="") as handle:
273
+ writer = csv.DictWriter(handle, fieldnames=fieldnames)
274
+ writer.writeheader()
275
+ for row, weight in zip(rows, result.weights, strict=True):
276
+ writer.writerow({**row, output_weight_column: _format_weight(weight)})
277
+
278
+
279
+ def read_model_package(path: str | Path) -> dict[str, object]:
280
+ """Read a linked household/person model package JSON.
281
+
282
+ Model packages are prepared artifacts created by SynthPopCan tooling. They
283
+ can represent linked household/person generation without exposing raw
284
+ microdata rows.
285
+
286
+ Parameters
287
+ ----------
288
+ path:
289
+ Path to a linked model package JSON file.
290
+
291
+ Returns
292
+ -------
293
+ dict[str, object]
294
+ The parsed package payload. Pass it to :func:`generate_from_model`.
295
+
296
+ Raises
297
+ ------
298
+ ValueError
299
+ Raised when the file is not valid JSON, is not a JSON object, or uses an
300
+ unsupported package schema.
301
+ """
302
+
303
+ try:
304
+ payload = json.loads(Path(path).read_text())
305
+ except json.JSONDecodeError as exc:
306
+ raise ValueError(f"{path} is not valid JSON") from exc
307
+ if not isinstance(payload, dict):
308
+ raise ValueError("model package must be a JSON object")
309
+ if payload.get("schema_version") != "synthpopcan-linked-tree-package-v1":
310
+ raise ValueError("unsupported linked model package schema")
311
+ return payload
312
+
313
+
314
+ def generate_from_model(
315
+ package: ModelPackageInput,
316
+ *,
317
+ households: int,
318
+ conditions: Mapping[str, str] | None = None,
319
+ random_seed: int | None = None,
320
+ household_size_column: str | None = None,
321
+ require_publishable: bool = True,
322
+ ) -> LinkedPopulation:
323
+ """Generate linked household and person rows from a prepared package.
324
+
325
+ This is the beginner-facing entry point for using an existing model package.
326
+ It creates household rows first, then generates person rows inside those
327
+ households using the package's linked household/person model design.
328
+
329
+ Parameters
330
+ ----------
331
+ package:
332
+ A path to a linked model package JSON file, or an already-loaded package
333
+ mapping returned by :func:`read_model_package`.
334
+ households:
335
+ Number of synthetic households to generate.
336
+ conditions:
337
+ Optional fixed values for package condition columns. For example,
338
+ ``{"geo": "Demo North"}`` asks the household model to generate rows for
339
+ that geography when the package supports ``geo`` as a condition.
340
+ random_seed:
341
+ Optional random seed for reproducible generated rows.
342
+ household_size_column:
343
+ Optional override for the column that controls how many person rows are
344
+ generated in each household. When omitted, the package setting is used,
345
+ falling back to ``household_size``.
346
+ require_publishable:
347
+ When ``True``, reject packages that are not marked as publishable
348
+ candidates. Keep this enabled for normal use; set it to ``False`` only
349
+ while inspecting trusted local development packages.
350
+
351
+ Returns
352
+ -------
353
+ LinkedPopulation
354
+ Household and person rows generated from the package.
355
+
356
+ Raises
357
+ ------
358
+ ValueError
359
+ Raised when the package schema is invalid, required models are missing,
360
+ an unsupported model type is encountered, or ``require_publishable`` is
361
+ enabled for a package that is not marked as publishable.
362
+
363
+ Examples
364
+ --------
365
+ >>> package = read_model_package("demo-linked-package.json")
366
+ >>> population = generate_from_model(package, households=25, random_seed=13)
367
+ >>> len(population.households)
368
+ 25
369
+ """
370
+
371
+ package_payload = _model_package(package)
372
+ if require_publishable:
373
+ _validate_publishable_package(package_payload)
374
+ package_household_size_column = str(
375
+ household_size_column or package_payload.get("household_size_column", "")
376
+ )
377
+ if not package_household_size_column:
378
+ package_household_size_column = "household_size"
379
+ household_model, person_model = _package_models(package_payload)
380
+ household_rows, person_rows = generate_linked_population(
381
+ household_model,
382
+ person_model,
383
+ households=households,
384
+ household_conditions=dict(conditions or {}),
385
+ household_size_column=package_household_size_column,
386
+ random_seed=random_seed,
387
+ )
388
+ return LinkedPopulation(household_rows, person_rows)
389
+
390
+
391
+ def write_population(
392
+ population: LinkedPopulation | PopulationRows,
393
+ path: str | Path,
394
+ ) -> None:
395
+ """Write generated population rows to CSV.
396
+
397
+ The output shape depends on the kind of population passed in:
398
+
399
+ * a :class:`LinkedPopulation` is written to a directory containing
400
+ ``households.csv`` and ``persons.csv``;
401
+ * a flat list of row dictionaries is written to a single CSV file.
402
+
403
+ Parameters
404
+ ----------
405
+ population:
406
+ Either a linked household/person population from
407
+ :func:`generate_from_model`, or a flat list of row dictionaries such as
408
+ the result from :func:`expand_population`.
409
+ path:
410
+ Destination directory for linked output, or destination CSV path for
411
+ flat output.
412
+
413
+ Raises
414
+ ------
415
+ ValueError
416
+ Raised when there are no rows to write.
417
+
418
+ Examples
419
+ --------
420
+ >>> fit = fit_ipf("seed.csv", "controls.csv")
421
+ >>> rows = expand_population(fit)
422
+ >>> write_population(rows, "expanded.csv")
423
+ """
424
+
425
+ output_path = Path(path)
426
+ if isinstance(population, LinkedPopulation):
427
+ output_path.mkdir(parents=True, exist_ok=True)
428
+ _write_rows(output_path / "households.csv", population.households)
429
+ _write_rows(output_path / "persons.csv", population.persons)
430
+ return
431
+ _write_rows(output_path, population)
432
+
433
+
434
+ def _seed_records(seed: SeedInput) -> list[Record]:
435
+ if isinstance(seed, str | Path):
436
+ return read_seed(seed)
437
+ return list(seed)
438
+
439
+
440
+ def _control_margins(controls: ControlInput) -> list[IPFMargin]:
441
+ if isinstance(controls, str | Path):
442
+ return read_controls(controls).to_ipf_margins()
443
+ if isinstance(controls, ControlTable):
444
+ return controls.to_ipf_margins()
445
+ return list(controls)
446
+
447
+
448
+ def _model_package(package: ModelPackageInput) -> dict[str, object]:
449
+ if isinstance(package, str | Path):
450
+ return read_model_package(package)
451
+ return dict(package)
452
+
453
+
454
+ def _validate_publishable_package(package: Mapping[str, object]) -> None:
455
+ privacy = package.get("privacy")
456
+ if (
457
+ not isinstance(privacy, Mapping)
458
+ or privacy.get("publishable_candidate") is not True
459
+ ):
460
+ raise ValueError(
461
+ "model package is not marked as a publishable candidate; inspect the "
462
+ "package before generating from it"
463
+ )
464
+
465
+
466
+ def _package_models(
467
+ package: Mapping[str, object],
468
+ ) -> tuple[FrequencyTreeModel | CartTreeModel, FrequencyTreeModel | CartTreeModel]:
469
+ models = package.get("models")
470
+ if not isinstance(models, Mapping):
471
+ raise ValueError("linked model package must include models")
472
+ household_model = _tree_model_from_payload(models.get("household"))
473
+ person_model = _tree_model_from_payload(models.get("person"))
474
+ return household_model, person_model
475
+
476
+
477
+ def _tree_model_from_payload(payload: object) -> FrequencyTreeModel | CartTreeModel:
478
+ if not isinstance(payload, dict):
479
+ raise ValueError(
480
+ "linked model package must include household and person models"
481
+ )
482
+ model_type = payload.get("model_type")
483
+ if model_type == "conditional-frequency":
484
+ return FrequencyTreeModel.from_dict(payload)
485
+ if model_type == "cart":
486
+ return CartTreeModel.from_dict(payload)
487
+ raise ValueError("unsupported tree model type in linked package")
488
+
489
+
490
+ def _write_rows(path: Path, rows: PopulationRows) -> None:
491
+ if not rows:
492
+ raise ValueError(f"cannot write empty rows to {path}")
493
+ path.parent.mkdir(parents=True, exist_ok=True)
494
+ with path.open("w", newline="") as handle:
495
+ writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
496
+ writer.writeheader()
497
+ writer.writerows(rows)
498
+
499
+
500
+ def _string_row(record: Mapping[str, object]) -> dict[str, str]:
501
+ return {str(key): str(value) for key, value in record.items()}
502
+
503
+
504
+ def _default_weight_column(rows: PopulationRows) -> str:
505
+ return "weight" if "weight" not in rows[0] else "fitted_weight"
506
+
507
+
508
+ def _format_weight(weight: float) -> str:
509
+ return str(int(weight)) if weight.is_integer() else f"{weight:.12g}"