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

Add Microcosm's regime-gated QRF to the MOOP imputation experiment - #223

Merged
MaxGhenis merged 5 commits into
mainfrom
max/moop-spm-microcosm-qrf
Nov 8, 2026
Merged

MaxGhenis merged 5 commits into
mainfrom
max/moop-spm-microcosm-qrf

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Why

Microcosm builds the microdata that PolicyEngine's app loads by default in both countries, and its microcosm-fit package imputes with RegimeGatedQRF. The app still offers the enhanced FRS for the UK, and policyengine-uk-data imputes several of that dataset's variables with microimpute's QRF. The MOOP experiment from #222 measured microimpute's QRF and ZI-QRF, so its forest results describe a model that no longer imputes PolicyEngine's default microdata. This PR adds the model that does.

What changes

  • methods.py adds microcosm_qrf, which fits RegimeGatedQRF on the same five folds of whole households, with the same predictors, draws and seed as the other methods. It also adds microcosm_threads, which caps Microcosm's fit and predict threads, and its gate's OpenMP threads, at --n-jobs.
    • microcosm_qrf passes each donor SPM unit's weight (SPM_WEIGHT / 100), since Microcosm weights its fits by default.
    • microcosm_qrf_unweighted passes NO_WEIGHTS, so it fits without weights, like every other method here.
  • run.py records the commit each Microcosm package came from (its PEP 610 direct_url.json), hashes Microcosm's fit, frame and graph sources into the cache key, and stops with a message when Microcosm is missing, unless --skip-forests. It raises when a Microcosm distribution's metadata names no version, and when a Microcosm package imports without distribution metadata.
  • render.py, NOTE.template.md and README.md report both fits beside microimpute's, and render.py guards the note's claims (below).
  • A full run regenerated results/results.json, NOTE.md and TABLE.md (cache tag 6ca09058c0101fce), with microcosm-graph, microcosm-frame and microcosm-fit 0.1.0 at PolicyEngine/microcosm@7080ba8 on Python 3.13.13.

Results

On the 2026 CPS ASEC, the SPM rate with reported MOOP comes to 13.11%, and subtracting MOOP raises it by 2.25 points.

Method Change in SPM rate, pp (SE) Total MOOP vs reported Units with zero MOOP 99th percentile MOOP
Reported 6.4% $33,640
(a) Logit + cell mean +0.30 (0.08) +2.4% 0.5% $15,585
(b) Logit draw + donor +0.39 (0.07) -0.5% 6.5% $33,147
PMM +0.32 (0.07) -1.5% 6.2% $32,666
(c) microimpute QRF -0.07 (0.07) -3.6% 6.2% $31,356
(c) microimpute ZI-QRF -0.05 (0.07) -3.7% 6.1% $31,424
(c) Microcosm QRF, weighted +0.06 (0.07) -1.0% 6.4% $33,749
(c) Microcosm QRF, unweighted +0.00 (0.06) -2.0% 6.3% $32,872
  • All four QRF fits leave the national rate within 0.1 point of its level with reported MOOP. The Census-style methods raise it by 0.30 to 0.39 points.
  • Of the four forests, Microcosm's weighted fit comes closest to the reported MOOP total: 1.0% short, against 3.6% and 3.7% for microimpute's. It matches the reported share of units at zero and comes within $110 of the 99th percentile.
  • For people in units whose resources before MOOP run 1.0 to 1.25 times their threshold, Microcosm's weighted fit raises poverty by 0.42 points, and cell means raise it by 14.69.
  • In SPM units where Census imputes neither MOOP nor the Medicare premium, the four QRF fits change the rate by +0.01 to +0.14 points (SE 0.09 to 0.10).

Invariants and tests

With Microcosm in the environment, tests/test_moop_spm_experiment.py passes all 19 tests.

  • A Hypothesis property test checks that each unweighted draw equals zero or lies within the range of the donor spenders' MOOP, and that two unweighted draws differ. When spenders above a cap carry zero weight, each weighted draw equals zero or lies within the range of the spenders that keep their weight, so the weights reach the forest. In both fits, n_jobs=1 and n_jobs=2 give identical draws.
  • A spy on the gate's HistGradientBoostingClassifier.fit checks that Microcosm fits the gate on each donor's predictors and on whether that donor spends, row by row in donor order. It also checks that the weighted fit hands the gate each donor's weight (up to one scale factor), and that the unweighted fit hands it none. Shuffling the rows, reversing the labels, or permuting or dropping the weights fails it.
  • microcosm_threads sets the widths that Microcosm's own _fit_n_jobs and _predict_workers read, and restores the environment afterwards.
  • installed_source raises when a distribution's metadata names no version, and installed_package_sha256 raises when a package exists but fails to import. environment() records None for a Microcosm package only when the package neither imports nor has distribution metadata, and raises when it imports without metadata.
  • The cache tag changes when Microcosm's commit or source changes.
  • render.py refuses to write the note if any QRF fit moves the national rate by 0.1 point or more, if any forest's total reaches the reported total, or if cell means, donor draws, PMM or Microcosm's weighted fit fail to raise poverty in the 1.0 to 1.25 band.

Reproducibility

  • The full run refit every fold, since the cache key changed, and reproduced all 1,286 result fields that Add experiment: how MOOP imputation methods change SPM poverty (CPS ASEC 2026) #222 published, unchanged. It adds 209 fields.
  • A cold refit of fold 0 reproduced every method's draws exactly, including the Microcosm fits.
  • The first two rounds of review fixes edited methods.py and run.py, two of the files the cache key hashes, so a full run after each round refit every fold. Each time, of the 1,613 fields in results.json, only the two source hashes, the tag and six timings changed, and TABLE.md rendered unchanged. No run logged Microcosm's near-discrete leaf cap.
  • The third round's fixes touch only the README and the note template, which the cache key leaves out. A re-render changed one phrase in NOTE.md and left results.json and TABLE.md as they were.
  • The fourth round's fixes touch only the README and the test file, which the cache key also leaves out, so results.json, NOTE.md and TABLE.md stay as they were.

Changes after review

First review

An independent Codex review of 1eea236 asked for changes: two blocking README errors, three should-fixes and one false sentence in this description. Commit e8030a5 answered each one.

  • microimpute's quantile mapping (blocking). The README said microimpute picks the nearest of 101 points. It now says what the code does: microimpute rounds each unit's quantile q to k/100 and reads the forest's (k + 1)/102 quantile, so q = 0.01 reads 2/102.
  • Leaf retention (blocking). The README said Microcosm keeps every leaf sample. e8030a5 limited that to targets with more than 32 distinct values, a rule the second review found still counted the wrong values (below).
  • Default weights. The microcosm_qrf docstring now says Microcosm's fit asks for design weights by default and raises when a frame carries another kind.
  • Versionless metadata. installed_source now reads the version through metadata.get("Version") and raises when the metadata names none; a new test case builds such a distribution.
  • Gate weights. A new test spies on the gate's fit and checks the weights in donor order.
  • CI. The section below now describes the matrix as it runs.

Second review

An independent Claude Opus review of e8030a5 verified five of those six answers and found one blocking error, one should-fix and four nits. Commit 959ab7d answers each one.

  • Leaf retention (blocking). The README stated Microcosm's leaf rule in terms of the whole target. Microcosm counts the values of the donors it hands each forest, so zeros never count, and a mixed-sign target's two forests count separately. The README now says Microcosm caps a forest's leaves at 4,096 samples only when those donors hold 2 to 32 distinct values. MOOP's forest trains on the donor spenders, whose amounts take 7,830 to 7,880 distinct values in each fold.
  • Weight forms. The microcosm_qrf docstring now says a caller fitting a DataFrame must pass a weight column's name, a weight vector or NO_WEIGHTS; microcosm_qrf passes a vector.
  • Row alignment. The gate spy now records each fit's features and labels as well as its weights and checks them against the donors' rows, so a row shuffle fails it.
  • Packages without metadata. installed_source returns None for a package that imports without distribution metadata, so environment() now raises in that case for Microcosm's packages. A new test covers it and runs without Microcosm.
  • Prose. I moved the test docstring's skip note out of its bullet list, and reworded participial passives in the README, the note template, the changelog fragment and the docstrings without changing what they claim.

Third review

An independent Claude Opus review of 959ab7d approved and raised two nits. Commit 1f9fd7f answers both.

  • Pronoun. The note template said "a forest it grows on donor spenders" right after the gate's classifier, so "it" could mean the classifier. It now says "a forest Microcosm grows on donor spenders".
  • Count order. The README now says Microcosm counts the distinct values of the donors it hands a forest, not those of the forest's weighted resample.

Fourth review

An independent Claude Opus review of 1f9fd7f found two false sentences and one overbroad sentence in this description, and three nits. Commit 1e75f7e and this description answer each one.

  • Default microdata (blocking). This description said PolicyEngine no longer imputes its microdata with microimpute and no longer runs microimpute's QRF. The app still offers the enhanced FRS for the UK, and policyengine-uk-data imputes several of its variables with microimpute's QRF, so "Why" now limits the claim to the microdata the app loads by default.
  • Versionless metadata. run.py raises on a versionless distribution only for Microcosm's three packages, so the run.py bullet now says "a Microcosm distribution".
  • Count order. In a weighted fit, _fit_forest draws its resample first and then counts the distinct values of the rows Microcosm handed it, so the README's "which it counts before any weighted resample" gave the wrong order. It now says the donors hold 2 to 32 distinct values before any weighted resample.
  • Weighted draws. The property test checked the draws' range and thread independence only for the unweighted fit. It now also checks that each weighted draw equals zero or lies within the range of the spenders that keep their weight, and that n_jobs=2 leaves the weighted draws unchanged. A mutant that ignores the weights fails it.
  • Prose. This description no longer calls the folds "household-grouped" or repeats the cache tag.

CI

CI's Python 3.14 job installs .[dev,matching] and runs every test (make test); its 3.12 job installs .[dev] and runs only tests/test_smoke_qrf.py and tests/test_basic.py. Neither installs Microcosm, which needs Python 3.13 or later and installs from git. The experiment imports Microcosm only inside its method and its run checks, so on 3.14 this test file passes 16 tests and skips the three Microcosm tests.

Merging releases a patch version (changelog.d/moop-spm-microcosm-qrf.changed.md).

🤖 Generated with Claude Code

Fit Microcosm's RegimeGatedQRF (microcosm-fit at PolicyEngine/microcosm
7080ba8), the model that imputes PolicyEngine's microdata, on the same
five household-grouped folds: once with the ASEC SPM unit weights, as
Microcosm weights its fits by default, and once without weights, like
the other methods. The note and tables report both fits beside
microimpute's QRF and ZI-QRF.

run.py records the commit each Microcosm package came from, hashes
Microcosm's source into the cache key and caps Microcosm's threads at
--n-jobs. Microcosm installs from git and needs Python 3.13, so the
experiment imports it only inside the Microcosm method and its tests
skip without it.

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 8, 2026 1:35am UTC

- README: describe microimpute's draw grid as its code works (it rounds
  each quantile q to k/100 and reads the forest's (k + 1)/102 quantile),
  and limit Microcosm's full leaf retention to targets with more than 32
  distinct values; it caps leaves at 4,096 samples for 2 to 32.
- methods.py: Microcosm's fit asks for design weights by default; a
  DataFrame fit must name its weights or pass NO_WEIGHTS.
- run.py: installed_source reads the version through metadata.get and
  raises when the metadata names none.
- tests: spy on the gate's fit to check each donor's weight in donor
  order, and add a versionless-metadata case.
- results.json: a full rerun under cache tag 541e509d20712573 changed
  only the two source hashes, the tag and six timings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- README: Microcosm caps a forest's leaves at 4,096 samples only when the
  donors that forest trains on hold 2 to 32 distinct values. MOOP's forest
  trains on the donor spenders, whose amounts take 7,830 to 7,880 distinct
  values in each fold.
- methods.py: a caller fitting a DataFrame must pass a weight column's
  name, a weight vector or NO_WEIGHTS.
- run.py: environment() raises when a Microcosm package imports without
  distribution metadata, and a new test covers that without Microcosm.
- tests: the gate spy checks each fit's features and labels against the
  donors' rows as well as the weights, and the module docstring's skip
  note moves out of the bullet list.
- Reword participial passives in the README, the note template, the
  changelog fragment and the docstrings.
- A full rerun refit every fold under cache tag 6ca09058c0101fce. In
  results.json only the two source hashes, the tag and six timings
  changed; TABLE.md renders unchanged, and NOTE.md changes only in the
  reworded template phrase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The note template now says "a forest Microcosm grows on donor
  spenders", since "it" could mean the gate's classifier. NOTE.md
  re-renders with that one phrase changed.
- The README now says Microcosm counts a forest's distinct donor values
  before any weighted resample, as _fit_forest does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The README now says the donors hold 2 to 32 distinct values before any
  weighted resample. In a weighted fit, Microcosm's _fit_forest draws the
  resample first and then counts the values of the rows it receives, so
  "counts before" gave the wrong order.
- The property test now checks that each weighted draw equals zero or
  lies between the smallest and largest spender that keeps its weight,
  and that n_jobs=2 leaves the weighted draws unchanged, as the test
  module's docstring says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review November 8, 2026 02:00
@MaxGhenis
MaxGhenis merged commit 13dad23 into main Nov 8, 2026
7 checks passed
@MaxGhenis
MaxGhenis deleted the max/moop-spm-microcosm-qrf branch November 8, 2026 06:28

This branch was successfully deployed

1 active deployment
Preview — 1e75f7ed Deployed Nov 8, 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