microimpute 3.1.4__tar.gz → 3.2.1__tar.gz

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 (49) hide show
  1. {microimpute-3.1.4 → microimpute-3.2.1}/PKG-INFO +2 -1
  2. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute.egg-info/PKG-INFO +2 -1
  3. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute.egg-info/SOURCES.txt +1 -0
  4. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute.egg-info/requires.txt +1 -0
  5. {microimpute-3.1.4 → microimpute-3.2.1}/pyproject.toml +2 -1
  6. microimpute-3.2.1/tests/test_moop_spm_experiment.py +660 -0
  7. {microimpute-3.1.4 → microimpute-3.2.1}/README.md +0 -0
  8. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/__init__.py +0 -0
  9. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/comparisons/__init__.py +0 -0
  10. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/comparisons/autoimpute.py +0 -0
  11. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/comparisons/autoimpute_helpers.py +0 -0
  12. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/comparisons/imputations.py +0 -0
  13. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/comparisons/metrics.py +0 -0
  14. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/comparisons/validation.py +0 -0
  15. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/config.py +0 -0
  16. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/evaluations/__init__.py +0 -0
  17. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/evaluations/cross_validation.py +0 -0
  18. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/evaluations/predictor_analysis.py +0 -0
  19. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/models/__init__.py +0 -0
  20. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/models/imputer.py +0 -0
  21. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/models/matching.py +0 -0
  22. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/models/mdn.py +0 -0
  23. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/models/ols.py +0 -0
  24. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/models/qrf.py +0 -0
  25. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/models/quantreg.py +0 -0
  26. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/models/zero_inflated.py +0 -0
  27. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/py.typed +0 -0
  28. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/utils/__init__.py +0 -0
  29. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/utils/dashboard_formatter.py +0 -0
  30. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/utils/data.py +0 -0
  31. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/utils/statmatch_hotdeck.py +0 -0
  32. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/utils/type_handling.py +0 -0
  33. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/visualizations/__init__.py +0 -0
  34. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/visualizations/comparison_plots.py +0 -0
  35. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute/visualizations/performance_plots.py +0 -0
  36. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute.egg-info/dependency_links.txt +0 -0
  37. {microimpute-3.1.4 → microimpute-3.2.1}/microimpute.egg-info/top_level.txt +0 -0
  38. {microimpute-3.1.4 → microimpute-3.2.1}/setup.cfg +0 -0
  39. {microimpute-3.1.4 → microimpute-3.2.1}/tests/test_autoimpute.py +0 -0
  40. {microimpute-3.1.4 → microimpute-3.2.1}/tests/test_basic.py +0 -0
  41. {microimpute-3.1.4 → microimpute-3.2.1}/tests/test_dashboard_formatter.py +0 -0
  42. {microimpute-3.1.4 → microimpute-3.2.1}/tests/test_data_preprocessing.py +0 -0
  43. {microimpute-3.1.4 → microimpute-3.2.1}/tests/test_metrics.py +0 -0
  44. {microimpute-3.1.4 → microimpute-3.2.1}/tests/test_predictor_analysis.py +0 -0
  45. {microimpute-3.1.4 → microimpute-3.2.1}/tests/test_public_api.py +0 -0
  46. {microimpute-3.1.4 → microimpute-3.2.1}/tests/test_quantile_comparison.py +0 -0
  47. {microimpute-3.1.4 → microimpute-3.2.1}/tests/test_smoke_qrf.py +0 -0
  48. {microimpute-3.1.4 → microimpute-3.2.1}/tests/test_type_handling.py +0 -0
  49. {microimpute-3.1.4 → microimpute-3.2.1}/tests/test_visualizations.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microimpute
3
- Version: 3.1.4
3
+ Version: 3.2.1
4
4
  Summary: Benchmarking imputation methods for microdata
5
5
  Author-email: Vahid Ahmadi <vahid@policyengine.org>, Max Ghenis <max@policyengine.org>, María Juaristi <juaristi@uni.minerva.edu>, Nikhil Woodruff <nikhil.woodruff@outlook.com>
6
6
  Project-URL: Repository, https://github.com/PolicyEngine/microimpute
@@ -34,6 +34,7 @@ Requires-Dist: ruff<0.17.0,>=0.16.0; extra == "dev"
34
34
  Requires-Dist: mypy<2.0.0,>=1.2.3; extra == "dev"
35
35
  Requires-Dist: build<2.0.0,>=1.2.0; extra == "dev"
36
36
  Requires-Dist: towncrier>=24.8.0; extra == "dev"
37
+ Requires-Dist: hypothesis<7.0.0,>=6.100.0; extra == "dev"
37
38
  Provides-Extra: matching
38
39
  Requires-Dist: rpy2<4.0.0,>=3.5.0; extra == "matching"
39
40
  Provides-Extra: mdn
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microimpute
3
- Version: 3.1.4
3
+ Version: 3.2.1
4
4
  Summary: Benchmarking imputation methods for microdata
5
5
  Author-email: Vahid Ahmadi <vahid@policyengine.org>, Max Ghenis <max@policyengine.org>, María Juaristi <juaristi@uni.minerva.edu>, Nikhil Woodruff <nikhil.woodruff@outlook.com>
6
6
  Project-URL: Repository, https://github.com/PolicyEngine/microimpute
@@ -34,6 +34,7 @@ Requires-Dist: ruff<0.17.0,>=0.16.0; extra == "dev"
34
34
  Requires-Dist: mypy<2.0.0,>=1.2.3; extra == "dev"
35
35
  Requires-Dist: build<2.0.0,>=1.2.0; extra == "dev"
36
36
  Requires-Dist: towncrier>=24.8.0; extra == "dev"
37
+ Requires-Dist: hypothesis<7.0.0,>=6.100.0; extra == "dev"
37
38
  Provides-Extra: matching
38
39
  Requires-Dist: rpy2<4.0.0,>=3.5.0; extra == "matching"
39
40
  Provides-Extra: mdn
@@ -38,6 +38,7 @@ tests/test_basic.py
38
38
  tests/test_dashboard_formatter.py
39
39
  tests/test_data_preprocessing.py
40
40
  tests/test_metrics.py
41
+ tests/test_moop_spm_experiment.py
41
42
  tests/test_predictor_analysis.py
42
43
  tests/test_public_api.py
43
44
  tests/test_quantile_comparison.py
@@ -19,6 +19,7 @@ ruff<0.17.0,>=0.16.0
19
19
  mypy<2.0.0,>=1.2.3
20
20
  build<2.0.0,>=1.2.0
21
21
  towncrier>=24.8.0
22
+ hypothesis<7.0.0,>=6.100.0
22
23
 
23
24
  [docs]
24
25
  jupyter-book
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "microimpute"
10
- version = "3.1.4"
10
+ version = "3.2.1"
11
11
  description = "Benchmarking imputation methods for microdata"
12
12
  readme = "README.md"
13
13
  authors = [
@@ -49,6 +49,7 @@ dev = [
49
49
  "mypy>=1.2.3,<2.0.0",
50
50
  "build>=1.2.0,<2.0.0",
51
51
  "towncrier>=24.8.0",
52
+ "hypothesis>=6.100.0,<7.0.0",
52
53
  ]
53
54
 
54
55
  matching = [
@@ -0,0 +1,660 @@
1
+ """Property tests for the MOOP-imputation SPM poverty experiment.
2
+
3
+ The experiment lives in ``experiments/moop_spm``. These tests use small
4
+ synthetic data, so they run without the CPS ASEC download. They check
5
+ the invariants the published comparison relies on:
6
+
7
+ - Fold assignment partitions households and keeps each household whole.
8
+ - Poverty is recomputed exactly from resources, MOOP and thresholds, and
9
+ it never falls when any unit's MOOP rises.
10
+ - People whose unit is poor before MOOP stay poor under any imputation.
11
+ - Weighted group rates, replicate SEs and weighted quantiles match direct
12
+ reference computations.
13
+ - Cell pools hold only same-cell donor spenders, and the nearest-donor
14
+ search agrees with a brute-force search.
15
+ - The logistic-participation variants follow their stated rules.
16
+ - Census's MOOP and Medicare premium imputations both count as allocated.
17
+ - Extracted CSVs that differ from the pinned zip are extracted again.
18
+ - The fold cache misses whenever the environment, microimpute's or
19
+ Microcosm's source or the model inputs change.
20
+ - ``installed_source`` raises, rather than reading a package as absent,
21
+ when its metadata names no version, and ``environment`` raises when a
22
+ Microcosm package imports without distribution metadata.
23
+ - Microcosm's draws are zero or lie within the range of the spenders
24
+ that carry weight, and they do not depend on the number of threads.
25
+ - Microcosm fits its gate on every donor's row: the donor's predictors
26
+ and whether it spends, with its unit weight in the weighted fit and no
27
+ weights in the unweighted fit.
28
+
29
+ The tests of Microcosm's draws, threads and gate skip on a machine
30
+ without Microcosm, which installs from git.
31
+ """
32
+
33
+ import argparse
34
+ import dataclasses
35
+ import importlib
36
+ import os
37
+ import re
38
+ import zipfile
39
+
40
+ import numpy as np
41
+ import pandas as pd
42
+ import pytest
43
+ from hypothesis import HealthCheck, given, settings
44
+ from hypothesis import strategies as st
45
+
46
+ from experiments.moop_spm import run as run_module
47
+ from experiments.moop_spm.cps_asec import allocation_flags, extract_verified
48
+ from experiments.moop_spm.evaluate import (
49
+ RATIO_BANDS,
50
+ evaluate,
51
+ group_rates,
52
+ poverty_flags,
53
+ ratio_band_index,
54
+ replicate_se,
55
+ weighted_quantile,
56
+ )
57
+ from experiments.moop_spm.methods import (
58
+ CELL_LEVELS,
59
+ PARTICIPATION_CUTOFF,
60
+ FoldData,
61
+ _key,
62
+ assign_cells,
63
+ fit_participation,
64
+ logit_family,
65
+ microcosm_qrf,
66
+ microcosm_threads,
67
+ nearest_donors,
68
+ participation_probability,
69
+ )
70
+ from experiments.moop_spm.run import (
71
+ assign_folds,
72
+ build_config,
73
+ collect_folds,
74
+ config_hash,
75
+ frame_sha256,
76
+ installed_package_sha256,
77
+ installed_source,
78
+ )
79
+
80
+ SETTINGS = settings(
81
+ max_examples=40,
82
+ deadline=None,
83
+ suppress_health_check=[HealthCheck.too_slow],
84
+ )
85
+
86
+
87
+ def synthetic_spm(seed: int, n_units: int = 60):
88
+ """Persons and units frames with the columns ``evaluate`` reads."""
89
+ rng = np.random.default_rng(seed)
90
+ unit_ids = np.arange(1, n_units + 1) * 7
91
+ threshold = rng.uniform(20_000, 40_000, n_units)
92
+ moop = np.where(rng.random(n_units) < 0.1, 0.0, rng.gamma(1.0, 5_000, n_units))
93
+ pre_moop = threshold * rng.uniform(0.5, 3.0, n_units)
94
+ units = pd.DataFrame(
95
+ {
96
+ "moop": moop,
97
+ "unit_weight": rng.uniform(100, 3_000, n_units),
98
+ "pre_moop_ratio": pre_moop / threshold,
99
+ "any_allocated": rng.integers(0, 2, n_units),
100
+ },
101
+ index=pd.Index(unit_ids, name="SPM_ID"),
102
+ )
103
+ sizes = rng.integers(1, 4, n_units)
104
+ person_unit = np.repeat(np.arange(n_units), sizes)
105
+ n_persons = len(person_unit)
106
+ persons = pd.DataFrame(
107
+ {
108
+ "SPM_ID": unit_ids[person_unit],
109
+ "A_AGE": rng.integers(0, 90, n_persons),
110
+ "PRDISFLG": rng.choice([-1, 1, 2], n_persons),
111
+ "coverage": rng.choice(
112
+ [
113
+ "medicare",
114
+ "employer",
115
+ "direct",
116
+ "medicaid",
117
+ "military",
118
+ "uninsured",
119
+ ],
120
+ n_persons,
121
+ ),
122
+ "SPM_MEDXPNS": moop[person_unit],
123
+ "SPM_RESOURCES": (pre_moop - moop)[person_unit],
124
+ "SPM_POVTHRESHOLD": threshold[person_unit],
125
+ "weight": rng.uniform(100, 3_000, n_persons),
126
+ }
127
+ )
128
+ persons["SPM_POOR"] = (
129
+ persons["SPM_RESOURCES"] < persons["SPM_POVTHRESHOLD"]
130
+ ).astype(int)
131
+ replicates = persons["weight"].to_numpy()[:, None] * rng.uniform(
132
+ 0.5, 1.5, (n_persons, 160)
133
+ )
134
+ replicate_frame = pd.DataFrame(
135
+ replicates, columns=[f"repwgt_{i}" for i in range(1, 161)]
136
+ )
137
+ persons = pd.concat([persons, replicate_frame], axis=1)
138
+ return persons, units
139
+
140
+
141
+ @SETTINGS
142
+ @given(
143
+ households=st.lists(st.integers(1, 40), min_size=5, max_size=120),
144
+ n_folds=st.integers(2, 6),
145
+ seed=st.integers(0, 2**31),
146
+ )
147
+ def test_folds_partition_households(households, n_folds, seed):
148
+ series = pd.Series(households)
149
+ folds = assign_folds(series, n_folds, seed)
150
+ assert set(np.unique(folds)) <= set(range(n_folds))
151
+ # Every household lands in exactly one fold.
152
+ assert (series.groupby(folds).nunique().sum()) == series.nunique()
153
+ per_household = pd.Series(folds).groupby(series.to_numpy()).nunique()
154
+ assert (per_household == 1).all()
155
+ # Household counts per fold differ by at most one.
156
+ household_counts = series.groupby(folds).nunique()
157
+ assert household_counts.max() - household_counts.min() <= 1
158
+ assert (assign_folds(series, n_folds, seed) == folds).all()
159
+
160
+
161
+ @SETTINGS
162
+ @given(seed=st.integers(0, 2**31), bump_seed=st.integers(0, 2**31))
163
+ def test_poverty_recomputation_and_monotonicity(seed, bump_seed):
164
+ persons, units = synthetic_spm(seed)
165
+ reported = poverty_flags(persons, units, units["moop"].to_numpy())[0]
166
+ assert (reported == (persons["SPM_POOR"] == 1)).all()
167
+
168
+ bump = np.random.default_rng(bump_seed).uniform(0, 20_000, len(units))
169
+ higher = poverty_flags(persons, units, units["moop"].to_numpy() + bump)[0]
170
+ # More MOOP can only push people into poverty.
171
+ assert (higher >= reported).all()
172
+
173
+ zero = poverty_flags(persons, units, np.zeros(len(units)))[0]
174
+ assert (zero <= reported).all()
175
+ pre_moop_poor = persons["SPM_ID"].map(units["pre_moop_ratio"]).to_numpy() < 1.0
176
+ # Units poor before MOOP stay poor under any nonnegative MOOP.
177
+ assert zero[pre_moop_poor].all()
178
+ assert higher[pre_moop_poor].all()
179
+
180
+
181
+ @SETTINGS
182
+ @given(seed=st.integers(0, 2**31))
183
+ def test_group_rates_match_np_average(seed):
184
+ rng = np.random.default_rng(seed)
185
+ n = 50
186
+ flags = rng.random(n)
187
+ weights = rng.uniform(0.1, 10, (n, 3))
188
+ groups = rng.random((n, 4)) < 0.5
189
+ groups[0] = True # keep every group nonempty
190
+ rates = group_rates(flags, groups, weights)
191
+ for g in range(groups.shape[1]):
192
+ for w in range(weights.shape[1]):
193
+ expected = np.average(flags[groups[:, g]], weights=weights[groups[:, g], w])
194
+ assert rates[g, w] == pytest.approx(expected)
195
+ assert ((rates >= 0) & (rates <= 1)).all()
196
+
197
+
198
+ @SETTINGS
199
+ @given(
200
+ base=st.floats(-5, 5),
201
+ deviations=st.lists(st.floats(-1, 1), min_size=160, max_size=160),
202
+ scale=st.floats(-3, 3),
203
+ )
204
+ def test_replicate_se_formula(base, deviations, scale):
205
+ estimates = np.array([base] + [base + d for d in deviations])
206
+ expected = np.sqrt(4 / 160 * np.sum(np.square(deviations)))
207
+ assert replicate_se(estimates) == pytest.approx(expected)
208
+ assert replicate_se(scale * estimates) == pytest.approx(abs(scale) * expected)
209
+ assert replicate_se(np.full(161, base)) == 0
210
+
211
+
212
+ @SETTINGS
213
+ @given(
214
+ values=st.lists(st.floats(-1e6, 1e6), min_size=1, max_size=60),
215
+ q=st.floats(0.01, 0.99),
216
+ )
217
+ def test_weighted_quantile_equal_weights(values, q):
218
+ values = np.array(values)
219
+ result = weighted_quantile(values, np.ones(len(values)), q)
220
+ assert result in values
221
+ assert result == pytest.approx(np.quantile(values, q, method="inverted_cdf"))
222
+
223
+
224
+ def _cells(rng: np.random.Generator, n: int) -> pd.DataFrame:
225
+ return pd.DataFrame(
226
+ {
227
+ "group": rng.choice(["medicare", "employer", "public"], n),
228
+ "age_band": rng.choice(["u45", "45_64", "65p"], n),
229
+ "income_band": rng.choice(["lt1", "1_2", "2_4", "4p"], n),
230
+ "size_band": rng.choice(["1", "2", "3p"], n),
231
+ }
232
+ )
233
+
234
+
235
+ @SETTINGS
236
+ @given(seed=st.integers(0, 2**31), min_donors=st.integers(1, 40))
237
+ def test_cell_pools_are_same_cell_donor_spenders(seed, min_donors):
238
+ rng = np.random.default_rng(seed)
239
+ donors = _cells(rng, 400)
240
+ moop = np.where(rng.random(400) < 0.2, 0.0, rng.gamma(1.0, 3_000, 400))
241
+ recipients = _cells(rng, 50)
242
+ assignment = assign_cells(donors, moop, recipients, min_donors=min_donors)
243
+ spender = moop > 0
244
+ donor_keys = [_key(donors.loc[spender], columns) for columns in CELL_LEVELS]
245
+ recipient_keys = [_key(recipients, columns) for columns in CELL_LEVELS]
246
+ for i, (pool, level) in enumerate(zip(assignment.pools, assignment.levels)):
247
+ assert len(pool) >= min_donors
248
+ assert (pool > 0).all()
249
+ in_cell = donor_keys[level] == recipient_keys[level][i]
250
+ expected = np.sort(moop[spender][in_cell])
251
+ assert np.array_equal(np.sort(pool), expected)
252
+ # No finer level had enough donor spenders.
253
+ for finer in range(level):
254
+ assert (donor_keys[finer] == recipient_keys[finer][i]).sum() < min_donors
255
+ assert np.allclose(assignment.means(), [p.mean() for p in assignment.pools])
256
+
257
+
258
+ @SETTINGS
259
+ @given(
260
+ donor_pred=st.lists(st.floats(-50, 50, allow_nan=False), min_size=1, max_size=40),
261
+ recipient_pred=st.lists(
262
+ st.floats(-60, 60, allow_nan=False), min_size=1, max_size=20
263
+ ),
264
+ k=st.integers(1, 8),
265
+ )
266
+ def test_nearest_donors_matches_brute_force(donor_pred, recipient_pred, k):
267
+ donor_pred = np.array(donor_pred)
268
+ recipient_pred = np.array(recipient_pred)
269
+ nearest = nearest_donors(donor_pred, recipient_pred, k)
270
+ k_eff = min(k, len(donor_pred))
271
+ assert nearest.shape == (len(recipient_pred), k_eff)
272
+ for i, value in enumerate(recipient_pred):
273
+ assert len(set(nearest[i])) == k_eff
274
+ got = np.sort(np.abs(donor_pred[nearest[i]] - value))
275
+ brute = np.sort(np.abs(donor_pred - value))[:k_eff]
276
+ assert np.allclose(got, brute)
277
+
278
+
279
+ def _fold(seed: int) -> FoldData:
280
+ rng = np.random.default_rng(seed)
281
+ n_donor, n_recipient = 600, 80
282
+
283
+ def features(n):
284
+ return pd.DataFrame(
285
+ {
286
+ "x1": rng.normal(size=n),
287
+ "x2": rng.normal(size=n),
288
+ "x3": rng.integers(0, 2, n).astype(float),
289
+ }
290
+ )
291
+
292
+ X_donor = features(n_donor)
293
+ logit = 2.0 + 1.5 * X_donor["x1"].to_numpy()
294
+ spends = rng.random(n_donor) < 1 / (1 + np.exp(-logit))
295
+ moop = np.where(spends, rng.gamma(1.0, 4_000, n_donor), 0.0)
296
+ cells_donor = _cells(rng, n_donor)
297
+ cells_recipient = _cells(rng, n_recipient)
298
+ return FoldData(
299
+ X_donor=X_donor,
300
+ moop_donor=moop,
301
+ weight_donor=rng.uniform(100, 3_000, n_donor),
302
+ cells_donor=cells_donor,
303
+ group_donor=cells_donor["group"].to_numpy(),
304
+ X_recipient=features(n_recipient),
305
+ cells_recipient=cells_recipient,
306
+ group_recipient=cells_recipient["group"].to_numpy(),
307
+ )
308
+
309
+
310
+ @settings(max_examples=8, deadline=None)
311
+ @given(seed=st.integers(0, 2**31))
312
+ def test_logit_family_rules(seed):
313
+ fold = _fold(seed)
314
+ out = logit_family(fold, 4, np.random.default_rng(seed))
315
+ prob = participation_probability(
316
+ fit_participation(fold.X_donor, fold.moop_donor), fold.X_recipient
317
+ )
318
+ cells = assign_cells(fold.cells_donor, fold.moop_donor, fold.cells_recipient)
319
+ means = cells.means()
320
+ below = prob < PARTICIPATION_CUTOFF
321
+ for method, draws in out.items():
322
+ assert draws.shape == (4, len(prob))
323
+ assert (draws >= 0).all(), method
324
+ assert np.allclose(out["logit_cell_mean"][0], np.where(below, 0, means))
325
+ assert np.allclose(out["logit_expected_value"][0], prob * means)
326
+ assert (out["logit_reg_mean"][:, below] == 0).all()
327
+ assert (out["logit_group_mean"][:, below] == 0).all()
328
+ assert (out["logit_threshold_hotdeck"][:, below] == 0).all()
329
+ for i, pool in enumerate(cells.pools):
330
+ for method in ("logit_draw_hotdeck", "logit_threshold_hotdeck"):
331
+ positive = out[method][:, i][out[method][:, i] > 0]
332
+ assert np.isin(positive, pool).all()
333
+ drawn = out["logit_draw_cell_mean"][:, i]
334
+ assert np.all((drawn == 0) | np.isclose(drawn, means[i]))
335
+
336
+
337
+ @settings(max_examples=3, deadline=None)
338
+ @given(seed=st.integers(0, 2**31), cap_quantile=st.floats(0.3, 0.9))
339
+ def test_microcosm_draws_stay_within_weighted_spenders(seed, cap_quantile):
340
+ pytest.importorskip("microcosm.fit")
341
+ fold = _fold(seed)
342
+ n_recipients = len(fold.X_recipient)
343
+ spent = fold.moop_donor[fold.moop_donor > 0]
344
+
345
+ unweighted = microcosm_qrf(fold, 3, seed=seed, weighted=False, n_jobs=1)
346
+ assert unweighted.shape == (3, n_recipients)
347
+ assert np.isfinite(unweighted).all()
348
+ # A draw is zero or lies between the smallest and largest spender.
349
+ drawn = unweighted[unweighted != 0]
350
+ assert (drawn >= spent.min()).all()
351
+ assert (drawn <= spent.max()).all()
352
+ assert not np.array_equal(unweighted[0], unweighted[1])
353
+ # The thread count changes no draw.
354
+ again = microcosm_qrf(fold, 3, seed=seed, weighted=False, n_jobs=2)
355
+ assert np.array_equal(again, unweighted)
356
+
357
+ # Donors without weight shape no draw: with zero weight on every
358
+ # spender above the cap, a weighted draw is zero or lies between the
359
+ # smallest and largest spender that keeps its weight.
360
+ cap = float(np.quantile(spent, cap_quantile))
361
+ capped = dataclasses.replace(
362
+ fold,
363
+ weight_donor=np.where(fold.moop_donor > cap, 0.0, fold.weight_donor),
364
+ )
365
+ kept = capped.moop_donor[(capped.moop_donor > 0) & (capped.weight_donor > 0)]
366
+ weighted = microcosm_qrf(capped, 3, seed=seed, weighted=True, n_jobs=1)
367
+ assert weighted.shape == (3, n_recipients)
368
+ assert np.isfinite(weighted).all()
369
+ drawn = weighted[weighted != 0]
370
+ assert (drawn >= kept.min()).all()
371
+ assert (drawn <= kept.max()).all()
372
+ assert weighted.max() <= cap
373
+ # The thread count changes no weighted draw either.
374
+ again = microcosm_qrf(capped, 3, seed=seed, weighted=True, n_jobs=2)
375
+ assert np.array_equal(again, weighted)
376
+
377
+
378
+ def test_microcosm_threads_sets_and_restores_widths(monkeypatch):
379
+ qrf_module = pytest.importorskip("microcosm.fit.qrf")
380
+ fit_name, draw_name = qrf_module._N_JOBS_ENV, qrf_module._PREDICT_WORKERS_ENV
381
+ monkeypatch.delenv(fit_name, raising=False)
382
+ monkeypatch.setenv(draw_name, "7")
383
+ with microcosm_threads(3):
384
+ # Microcosm's own resolvers read the cap.
385
+ assert qrf_module._fit_n_jobs() == 3
386
+ assert qrf_module._predict_workers() == 3
387
+ assert fit_name not in os.environ
388
+ assert os.environ[draw_name] == "7"
389
+
390
+
391
+ def test_microcosm_gate_takes_donor_weights_in_order(monkeypatch):
392
+ pytest.importorskip("microcosm.fit")
393
+ from sklearn.ensemble import HistGradientBoostingClassifier
394
+
395
+ recorded = []
396
+ original = HistGradientBoostingClassifier.fit
397
+
398
+ def spy(self, X, y, sample_weight=None, **kwargs):
399
+ weights = sample_weight
400
+ if weights is not None:
401
+ weights = np.array(weights, dtype=float)
402
+ recorded.append((np.array(X, dtype=float), np.array(y), weights))
403
+ return original(self, X, y, sample_weight=sample_weight, **kwargs)
404
+
405
+ monkeypatch.setattr(HistGradientBoostingClassifier, "fit", spy)
406
+ fold = _fold(7)
407
+ donors = fold.X_donor.to_numpy(dtype=float)
408
+ spends = (fold.moop_donor > 0).astype(int)
409
+ microcosm_qrf(fold, 1, seed=7, weighted=True, n_jobs=1)
410
+ # Microcosm fits one in-sample gate on every donor's row: the donor's
411
+ # predictors and whether it spends, with that donor's weight.
412
+ assert len(recorded) == 1
413
+ features, labels, weights = recorded[0]
414
+ assert np.array_equal(features, donors)
415
+ assert np.array_equal(labels, spends)
416
+ assert weights is not None
417
+ assert weights.shape == fold.weight_donor.shape
418
+ assert np.allclose(
419
+ weights / weights.sum(), fold.weight_donor / fold.weight_donor.sum()
420
+ )
421
+ # A reversed donor order would fail the same check.
422
+ assert not np.allclose(
423
+ weights / weights.sum(),
424
+ fold.weight_donor[::-1] / fold.weight_donor.sum(),
425
+ )
426
+
427
+ recorded.clear()
428
+ microcosm_qrf(fold, 1, seed=7, weighted=False, n_jobs=1)
429
+ assert len(recorded) == 1
430
+ features, labels, weights = recorded[0]
431
+ assert np.array_equal(features, donors)
432
+ assert np.array_equal(labels, spends)
433
+ assert weights is None
434
+
435
+
436
+ def test_installed_source_and_hash_tolerate_only_absent_packages(tmp_path, monkeypatch):
437
+ assert installed_source("no-such-distribution-moop") is None
438
+ assert installed_package_sha256("no_such_package_moop.sub") is None
439
+ # A package that is present but fails to import still raises.
440
+ broken = tmp_path / "broken_package_moop"
441
+ broken.mkdir()
442
+ (broken / "__init__.py").write_text("import no_such_dependency_moop\n")
443
+ # So does a distribution whose metadata names no version.
444
+ versionless = tmp_path / "versionless_moop-0.0.dist-info"
445
+ versionless.mkdir()
446
+ (versionless / "METADATA").write_text(
447
+ "Metadata-Version: 2.1\nName: versionless-moop\n"
448
+ )
449
+ monkeypatch.syspath_prepend(str(tmp_path))
450
+ importlib.invalidate_caches()
451
+ with pytest.raises(ModuleNotFoundError):
452
+ installed_package_sha256("broken_package_moop")
453
+ with pytest.raises(RuntimeError, match="names no version"):
454
+ installed_source("versionless-moop")
455
+ source = installed_source("microcosm-fit")
456
+ if source is not None:
457
+ # A git install records its commit.
458
+ assert re.fullmatch(r"\S+ @ ([0-9a-f]{40}|\S+)", source)
459
+
460
+
461
+ def test_environment_refuses_microcosm_imports_without_metadata(tmp_path, monkeypatch):
462
+ # Two packages import; only the first has distribution metadata.
463
+ for name in ("recorded_moop", "unrecorded_moop"):
464
+ (tmp_path / name).mkdir()
465
+ (tmp_path / name / "__init__.py").write_text("")
466
+ recorded = tmp_path / "recorded_moop-1.0.dist-info"
467
+ recorded.mkdir()
468
+ (recorded / "METADATA").write_text(
469
+ "Metadata-Version: 2.1\nName: recorded-moop\nVersion: 1.0\n"
470
+ )
471
+ monkeypatch.syspath_prepend(str(tmp_path))
472
+ importlib.invalidate_caches()
473
+ present = {
474
+ "recorded": ("recorded-moop", "recorded_moop"),
475
+ "absent": ("no-such-distribution-moop", "no_such_package_moop"),
476
+ }
477
+ monkeypatch.setattr(run_module, "MICROCOSM_PACKAGES", present)
478
+ found = run_module.environment()
479
+ assert found["recorded"] == "1.0"
480
+ assert found["absent"] is None
481
+ unrecorded = {"unrecorded": ("unrecorded-moop", "unrecorded_moop")}
482
+ monkeypatch.setattr(run_module, "MICROCOSM_PACKAGES", unrecorded)
483
+ with pytest.raises(RuntimeError, match="unrecorded_moop imports"):
484
+ run_module.environment()
485
+
486
+
487
+ @settings(max_examples=10, deadline=None)
488
+ @given(seed=st.integers(0, 2**31))
489
+ def test_evaluate_invariants(seed):
490
+ persons, units = synthetic_spm(seed, n_units=80)
491
+ bump = np.random.default_rng(seed + 1).uniform(0, 10_000, len(units))
492
+ imputations = {
493
+ "no_moop": np.zeros((1, len(units))),
494
+ "higher": (units["moop"].to_numpy() + bump)[None, :],
495
+ "same": np.tile(units["moop"].to_numpy(), (3, 1)),
496
+ }
497
+ results = evaluate(persons, units, imputations)
498
+ groups = results["groups"]
499
+ methods = results["methods"]
500
+ nonempty = np.array(results["group_person_counts"]) > 0
501
+ assert np.allclose(np.array(methods["same"]["diff"])[nonempty], 0)
502
+ assert methods["same"]["newly_poor_share"] == 0
503
+ assert methods["same"]["newly_nonpoor_share"] == 0
504
+ assert (np.array(methods["no_moop"]["diff"])[nonempty] <= 1e-12).all()
505
+ assert (np.array(methods["higher"]["diff"])[nonempty] >= -1e-12).all()
506
+ band = groups.index(["pre_moop_ratio", RATIO_BANDS[0][2]])
507
+ if nonempty[band]:
508
+ for method in methods.values():
509
+ assert method["rate"][band] == pytest.approx(1.0)
510
+
511
+
512
+ @SETTINGS
513
+ @given(
514
+ ratios=st.lists(
515
+ st.one_of(
516
+ st.floats(-5, 20, allow_nan=False),
517
+ st.sampled_from([1.0, 1.25, 1.5, 2.0, 3.0]),
518
+ ),
519
+ min_size=1,
520
+ max_size=50,
521
+ )
522
+ )
523
+ def test_ratio_band_index_matches_band_masks(ratios):
524
+ ratios = np.array(ratios)
525
+ index = ratio_band_index(ratios)
526
+ for b, (low, high, _) in enumerate(RATIO_BANDS):
527
+ mask = (ratios >= low) & (ratios < high)
528
+ assert np.array_equal(index == b, mask)
529
+
530
+
531
+ def test_allocation_flags_cover_moop_and_premium():
532
+ persons = pd.DataFrame(
533
+ {
534
+ "I_MOOP": [-1, 0, 1, 2, 3, 0, 0],
535
+ "I_MCPREM": [-1, -1, -1, 0, -1, 2, 0],
536
+ }
537
+ )
538
+ flags = allocation_flags(persons)
539
+ assert flags["allocated_moop"].tolist() == [0, 0, 1, 1, 1, 0, 0]
540
+ assert flags["allocated_premium"].tolist() == [0, 0, 0, 0, 0, 1, 0]
541
+
542
+
543
+ def test_extract_verified_replaces_altered_copies(tmp_path):
544
+ zip_path = tmp_path / "release.zip"
545
+ with zipfile.ZipFile(zip_path, "w") as archive:
546
+ archive.writestr("a.csv", "x,y\n1,2\n")
547
+ archive.writestr("b.csv", "z\n3\n")
548
+ out = tmp_path / "data"
549
+ out.mkdir()
550
+ extract_verified(zip_path, out, ["a.csv", "b.csv"])
551
+ assert (out / "a.csv").read_text() == "x,y\n1,2\n"
552
+
553
+ # A matching copy stays in place.
554
+ os.utime(out / "b.csv", ns=(1, 1))
555
+ extract_verified(zip_path, out, ["b.csv"])
556
+ assert (out / "b.csv").stat().st_mtime_ns == 1
557
+
558
+ # Altered copies, of the same or a different size, come back.
559
+ (out / "a.csv").write_text("x,y\n9,2\n")
560
+ (out / "b.csv").write_text("z\n3\n4\n")
561
+ extract_verified(zip_path, out, ["a.csv", "b.csv"])
562
+ assert (out / "a.csv").read_text() == "x,y\n1,2\n"
563
+ assert (out / "b.csv").read_text() == "z\n3\n"
564
+
565
+
566
+ def _cache_inputs(n_units: int = 12):
567
+ rng = np.random.default_rng(0)
568
+ index = pd.Index(np.arange(n_units) * 3 + 1, name="SPM_ID")
569
+ units = pd.DataFrame(
570
+ {
571
+ "moop": rng.gamma(1.0, 1_000, n_units),
572
+ "household": np.arange(n_units) // 2,
573
+ },
574
+ index=index,
575
+ )
576
+ X = pd.DataFrame({"x": rng.normal(size=n_units)}, index=index)
577
+ cells = pd.DataFrame({"group": ["employer"] * n_units}, index=index)
578
+ folds = assign_folds(units["household"], 3, 0)
579
+ args = argparse.Namespace(
580
+ folds=3, draws=2, seed=0, skip_forests=False, max_households=None
581
+ )
582
+ return args, units, X, cells, folds
583
+
584
+
585
+ def _stub_fit(value: float, folds: np.ndarray, calls: list):
586
+ def fit(fold: int) -> dict[str, np.ndarray]:
587
+ calls.append(fold)
588
+ n = int((folds == fold).sum())
589
+ return {
590
+ "qrf": np.full((2, n), value),
591
+ "_timings": np.array([[0.0]]),
592
+ "_timing_names": np.array(["qrf"]),
593
+ }
594
+
595
+ return fit
596
+
597
+
598
+ def test_cache_misses_when_environment_or_model_source_changes(tmp_path, monkeypatch):
599
+ args, units, X, cells, folds = _cache_inputs()
600
+ base_env = run_module.environment()
601
+ monkeypatch.setattr(run_module, "environment", lambda: base_env)
602
+ tag = config_hash(build_config(args, units, X, cells, folds))
603
+
604
+ calls: list = []
605
+ first, _ = collect_folds(tmp_path, tag, folds, 3, 2, _stub_fit(17.0, folds, calls))
606
+ assert calls == [0, 1, 2]
607
+ assert (first["qrf"] == 17.0).all()
608
+
609
+ # Same fingerprint: every fold comes from the cache.
610
+ calls.clear()
611
+ again, _ = collect_folds(tmp_path, tag, folds, 3, 2, _stub_fit(99.0, folds, calls))
612
+ assert calls == []
613
+ assert (again["qrf"] == 17.0).all()
614
+
615
+ # A new microimpute version, or edited microimpute source, refits.
616
+ newer = {**base_env, "microimpute": base_env["microimpute"] + ".post1"}
617
+ monkeypatch.setattr(run_module, "environment", lambda: newer)
618
+ new_tag = config_hash(build_config(args, units, X, cells, folds))
619
+ assert new_tag != tag
620
+ fresh, _ = collect_folds(
621
+ tmp_path, new_tag, folds, 3, 2, _stub_fit(99.0, folds, calls)
622
+ )
623
+ assert calls == [0, 1, 2]
624
+ assert (fresh["qrf"] == 99.0).all()
625
+
626
+ # Microcosm from another commit refits.
627
+ moved = {**base_env, "microcosm_fit": "0.1.0 @ another-commit"}
628
+ monkeypatch.setattr(run_module, "environment", lambda: moved)
629
+ assert config_hash(build_config(args, units, X, cells, folds)) != tag
630
+
631
+ monkeypatch.setattr(run_module, "environment", lambda: base_env)
632
+ assert config_hash(build_config(args, units, X, cells, folds)) == tag
633
+ # Edited Microcosm source refits, and so does edited microimpute source.
634
+ original = run_module.installed_package_sha256
635
+ monkeypatch.setattr(
636
+ run_module, "installed_package_sha256", lambda package: "edited"
637
+ )
638
+ assert config_hash(build_config(args, units, X, cells, folds)) != tag
639
+ monkeypatch.setattr(run_module, "installed_package_sha256", original)
640
+ assert config_hash(build_config(args, units, X, cells, folds)) == tag
641
+ monkeypatch.setattr(run_module, "package_sha256", lambda package: "edited")
642
+ assert config_hash(build_config(args, units, X, cells, folds)) != tag
643
+
644
+
645
+ def test_cache_key_tracks_model_inputs_and_row_keys():
646
+ args, units, X, cells, folds = _cache_inputs()
647
+ tag = config_hash(build_config(args, units, X, cells, folds))
648
+ assert config_hash(build_config(args, units, X, cells, folds)) == tag
649
+
650
+ changed = units.copy()
651
+ changed.iloc[0, 0] += 1.0
652
+ assert config_hash(build_config(args, changed, X, cells, folds)) != tag
653
+
654
+ rekeyed = [frame.copy() for frame in (units, X, cells)]
655
+ for frame in rekeyed:
656
+ frame.index = frame.index + 1
657
+ assert config_hash(build_config(args, *rekeyed, folds)) != tag
658
+
659
+ assert config_hash(build_config(args, units, X, cells, folds[::-1])) != tag
660
+ assert frame_sha256(units) != frame_sha256(units.rename(columns={"moop": "m"}))
File without changes
File without changes