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.
- synthpopcan/__init__.py +33 -0
- synthpopcan/api.py +509 -0
- synthpopcan/benchmarks.py +179 -0
- synthpopcan/calibration.py +201 -0
- synthpopcan/cli.py +1216 -0
- synthpopcan/cli_ipf.py +351 -0
- synthpopcan/cli_microdata.py +489 -0
- synthpopcan/cli_output.py +657 -0
- synthpopcan/cli_tree.py +2093 -0
- synthpopcan/console.py +121 -0
- synthpopcan/controls.py +803 -0
- synthpopcan/diagnostics.py +320 -0
- synthpopcan/ipf.py +368 -0
- synthpopcan/localdata.py +120 -0
- synthpopcan/microdata.py +1385 -0
- synthpopcan/models/__init__.py +266 -0
- synthpopcan/models/demo-linked-household-person-package.json +828 -0
- synthpopcan/sources.py +86 -0
- synthpopcan/statcan.py +526 -0
- synthpopcan/tree.py +1672 -0
- synthpopcan/tree_benchmark.py +368 -0
- synthpopcan/validation.py +303 -0
- synthpopcan/web/__init__.py +1 -0
- synthpopcan/web/app.mjs +555 -0
- synthpopcan/web/browser-job.mjs +27 -0
- synthpopcan/web/csv.mjs +87 -0
- synthpopcan/web/index.html +339 -0
- synthpopcan/web/ipf.mjs +208 -0
- synthpopcan/web/preview.mjs +79 -0
- synthpopcan/web/result-ui.mjs +151 -0
- synthpopcan/web/starter-files.mjs +56 -0
- synthpopcan/web/statcan.mjs +149 -0
- synthpopcan/web/styles.css +674 -0
- synthpopcan/web/synthpopcan-icon-64.png +0 -0
- synthpopcan/web/synthpopcan-logo-256.png +0 -0
- synthpopcan/web/tree-model.mjs +495 -0
- synthpopcan/web/wds-normalize.mjs +171 -0
- synthpopcan/web/worker.mjs +122 -0
- synthpopcan/web/workflow-views.mjs +127 -0
- synthpopcan/web/zip.mjs +99 -0
- synthpopcan/web_wds.py +217 -0
- synthpopcan/webapp.py +140 -0
- synthpopcan-0.1.0.dist-info/METADATA +157 -0
- synthpopcan-0.1.0.dist-info/RECORD +47 -0
- synthpopcan-0.1.0.dist-info/WHEEL +4 -0
- synthpopcan-0.1.0.dist-info/entry_points.txt +2 -0
- synthpopcan-0.1.0.dist-info/licenses/LICENSE +21 -0
synthpopcan/__init__.py
ADDED
|
@@ -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}"
|