Skip to content
This repository was archived by the owner on Nov 8, 2026. It is now read-only.

Add experiment: how MOOP imputation methods change SPM poverty (CPS ASEC 2026) - #222

Merged
MaxGhenis merged 7 commits into
mainfrom
max/moop-spm-imputation-20261105
Nov 6, 2026
Merged

MaxGhenis merged 7 commits into
mainfrom
max/moop-spm-imputation-20261105

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

We add experiments/moop_spm, which measures how the choice of method for imputing medical out-of-pocket spending (MOOP) changes Supplemental Poverty Measure (SPM) poverty. The American Community Survey (ACS) does not ask about MOOP, so researchers who build an SPM on the ACS impute it. The 2026 Current Population Survey Annual Social and Economic Supplement (CPS ASEC) asks about MOOP, and its public-use file covers calendar-year 2025. We impute MOOP within that file and check each method against the file's own MOOP. We compare logit models with cell means, OLS means or donor draws, predictive mean matching (PMM), and microimpute's quantile regression forests, alone (QRF) and zero-inflated (ZI-QRF). We summarize the results in NOTE.md and give the full tables in TABLE.md.

Results

We use the file's 134,729 people in 55,401 SPM units and 53,344 households.

  • With reported MOOP, we reproduce Census's published 2025 SPM rate (13.11%) and margins of error, for all people and each age group, to within 0.01 percentage point. Subtracting MOOP raises the rate by 2.25 points; Census reports 2.28.
  • We estimate that logit plus cell means raises the national SPM rate by 0.30 points (SE 0.08), the OLS variant by 0.34, logit draws with donors by 0.39 and PMM by 0.32. QRF changes it by -0.07 (SE 0.07) and ZI-QRF by -0.05 (SE 0.07), though their MOOP totals fall 3.6% and 3.7% short of the reported total.
  • For people whose resources before MOOP run 1.0 to 1.25 times their threshold, logit plus cell means raises the rate by 14.69 points, PMM by 4.19 and QRF by 0.55. Reported MOOP rises with resources ($2,385 on average in that band, $6,086 at 2 to 3 times the threshold), and cell means assign the band $3,189, PMM $2,799 and QRF $2,352.
  • Logit draws with donors match the reported share at zero (6.5% against 6.4%) and 99th percentile ($33,147 against $33,640), yet raise the rate by 0.39 points. Cell means cut the 99th percentile to $15,585.
  • Census imputes MOOP for 43% of the file's people and the Medicare premium for 13%, so part of our benchmark comes from its imputation. In the 44% of SPM units where it imputes neither for any member, the three logit methods and PMM raise the rate by 0.26 to 0.52 points. QRF and ZI-QRF change it by +0.01 (SE 0.09) and +0.07 (SE 0.10).

What the code does

  • cps_asec.py downloads the Census zip, checks its pinned SHA-256 and re-extracts any CSV that differs from the zip. It merges household state and the 160 replicate weights and flags people whose MOOP or Medicare premium Census imputed. It then builds SPM-unit predictors an ACS-style file carries: ages, sex, disability, current coverage, cash income, family type, tenure and state.
  • methods.py implements (a) logit plus cell or OLS means, (b) logit draws with donors, PMM within coverage groups and (c) microimpute QRF and ZeroInflatedImputer. It also implements the decomposition variants that cross the participation rule with the amount rule.
  • run.py assigns whole households to five folds, imputes each fold from models it fits on the rest (ten draws for stochastic methods) and writes results/results.json. It caches each fold under a key that covers the settings, the experiment's and microimpute's source files, package versions and the model inputs.
  • evaluate.py recomputes person-level SPM poverty for each method and computes rates, replicate-weight SEs, gross flows and MOOP distributions.
  • render.py writes TABLE.md and NOTE.md from results.json and fails if a placeholder in NOTE.template.md has no value.

Invariants

tests/test_moop_spm_experiment.py checks these on synthetic data:

  • With reported MOOP, recomputed poverty equals SPM_POOR for every person.
  • Raising any unit's MOOP never moves anyone out of poverty, and people whose unit's resources before MOOP fall below its threshold stay poor under any nonnegative imputation.
  • Folds partition households, and each household stays in one fold.
  • Cell pools hold only same-cell donor spenders, and each recipient uses the finest cell with enough donors.
  • Every logit method that uses the 0.5 cutoff gives zero to units below it, and no logit method imputes negative MOOP.
  • logit_draw_hotdeck and logit_threshold_hotdeck impute positive amounts only from the recipient's own cell pool.
  • For each recipient, the nearest-donor search returns min(k, number of donors) distinct donors, and their sorted distances match the smallest ones a brute-force search finds.
  • Weighted group rates, the replicate SE formula and the ratio-band index match direct reference computations, and weighted quantiles with equal weights match NumPy's.
  • allocation_flags marks a person's MOOP as Census-imputed when I_MOOP runs 1 to 3, and their Medicare premium when I_MCPREM equals 2.
  • A new microimpute version or source file, or changed model inputs or row keys, change the cache key, so run.py refits every fold.
  • extract_verified replaces any extracted CSV whose size or CRC-32 differs from the zip's member.

On every run, run.py also checks the identities that rebuild SPM_RESOURCES, SPM_POOR and SPM_MEDXPNS from their components. It raises if any method leaves a unit without an imputation or imputes negative MOOP.

Changes after review

GPT-6.1 Sol, reviewing da0144c, asked for these changes, which we make in 754e518:

  • We now also leave units where Census imputed a Medicare premium (I_MCPREM of 2) out of the comparison among units with no imputed MOOP, since SPM_MEDXPNS includes the premium. The subset falls from 52% to 44% of SPM units. Logit plus cell means' change there moves from +0.65 to +0.36, the OLS variant's from +0.57 to +0.26, PMM's from +0.56 to +0.48 and QRF's from +0.10 to +0.01. In every record, I_MOOP flags an imputation if and only if I_PHIPVAL, I_POTCVAL or I_PMEDVAL does, so it already covers MOOP's three components.
  • We now include microimpute's source files, package versions and the model inputs in the fold cache key, so run.py refits every fold when any of them changes.
  • In the note, we describe PMM's log(1 + MOOP) regression across all donors, including zero spenders, and split a 47-word sentence.

Rerunning from an empty cache at 754e518, we reproduced every other number in results.json to full precision.

Reviewing 754e518, Sol found no correctness, claim or reproducibility problems. Sol asked us to drop the bold labels that opened the results above and to give the README's predictor sentence an actor. We make both changes in 2a92477 and rewrite the other label openers and reduced passives in the README, note and table. No number changes.

Reviewing e283c4c, Sol found no computation or reproducibility defect and asked for three prose fixes, which we make in 12fd398. We now state the nearest-donor invariant as its test checks it, with matching distances instead of identical donors. In the note, we credit María Juaristi with the microimpute paper's finding, and in this section we drop a reduced passive. We also give the verb to a person or program wherever a document or commit held it, and narrow two more invariants to what their tests check.

Reviewing 12fd398, Sol asked us to narrow the donor-draw and nearest-donor invariants to what their tests check and to name Sol as the actor in this section. We make those changes in this description, with no new commit, and Sol approved them in a follow-up review at the same head.

Two microimpute behaviors we leave unchanged

  • ZeroInflatedImputer.fit passes weight_col only to the auxiliary imputer for non-numeric targets, so it fits numeric targets without weights. We fit every method without weights, so our results don't depend on it.
  • QRF's stochastic predict maps each draw onto a 101-point quantile grid from 1/102 to 101/102, so no draw exceeds the 101/102 quantile of a unit's predicted distribution.

Other changes

  • We add hypothesis to the dev extras for the property tests.

🤖 Generated with Claude Code

Cross-fits six MOOP imputation methods (logit plus cell or OLS means,
logit draw plus donor amounts, PMM within coverage groups, microimpute
QRF and zero-inflated QRF) on held-out SPM units of the 2026 CPS ASEC and
recomputes SPM poverty under each, with replicate-weight SEs. Property
tests cover the invariants on synthetic data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
microimpute-dashboard Ready Ready Preview Nov 6, 2026 4:47am UTC

Final 5-fold run on the 2026 CPS ASEC (134,729 people, 55,401 SPM
units). NOTE.md and TABLE.md render from results/results.json through
NOTE.template.md, so every number in them comes from the run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The robustness subset now drops units where Census imputed any
  member's MOOP (I_MOOP 1-3) or Medicare premium (I_MCPREM 2), since
  SPM_MEDXPNS includes both. results.json records each person share.
- The fold cache key now covers every microimpute source file, the
  versions of Python and the numerical packages, and a hash of the model
  inputs with their row keys. results.json takes its environment from
  the same dict. ensure_raw re-extracts any CSV whose size or CRC-32
  differs from the pinned zip's member.
- New tests cover the allocation flags, re-extraction, and cache misses
  on a changed environment, microimpute source, inputs or row keys.
- The note describes PMM's log(1 + MOOP) regression, splits a long
  sentence, gives "we" the method steps, and states the new subset
  results. A rerun from an empty cache reproduced every other number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GPT-6.1 Sol's review of 754e518 asked for two wording changes: drop the
bold labels that open the PR description's results, and give the
README's predictor sentence an actor. We make the README change and the
same pass over the rest of the text:

- README: the Data and Design bullets open with their sentences, and the
  predictor, cell-fallback, PMM and running text name who acts. The
  identity bullet names SPM_MEDXPNS and gives the poverty condition its
  own sentence.
- NOTE: each method bullet introduces the method's name in a sentence
  instead of a bold label, and the reproduction sentence splits in two.
- TABLE: render.py's source note, Table 2 caption and subset row name
  the actor and the weights.

No number changes, and results.json stays as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test rebuilt the donor and recipient cell keys for every recipient
and every finer level, about 250 row-wise pandas joins per example.
Building each level's keys once per example keeps every assertion and
cuts the test from 46 s to 2.6 s locally, and the suite from 52 s to
6.3 s. Assigners that skip the finest level, admit cells one donor
short or shift pools between recipients still fail it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tors

GPT-6.1 Sol's review of e283c4c found no computation or reproducibility
defect and asked for three wording fixes, two of them in the PR
description. In the note, María Juaristi now holds the verb for the
microimpute paper's finding, which keeps its scope (lowest average
quantile loss of five methods for imputing SCF net worth onto the CPS).
A pass for the same pattern moves the verbs that the README gave to
documents ("NOTE.md summarizes", "the data dictionary labels",
"results.json records") to us, Census and run.py, drops "(git-ignored)"
for "which git ignores", and replaces the note's "With drawn
participation" with "When we draw participation". No number changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review November 6, 2026 13:37
@MaxGhenis
MaxGhenis merged commit 6ef119d into main Nov 6, 2026
7 checks passed
@MaxGhenis
MaxGhenis deleted the max/moop-spm-imputation-20261105 branch November 6, 2026 13:54

This branch was successfully deployed

1 active deployment
Preview — 12fd398f Deployed Nov 6, 2026 by vercel[bot]
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant