Skip to content

Stop the US PUF imputation from predicting income from itself (#982) - #1033

Merged
MaxGhenis merged 12 commits into
mainfrom
us-982-puf-demographic-rank-predictors
Sep 26, 2026
Merged

MaxGhenis merged 12 commits into
mainfrom
us-982-puf-demographic-rank-predictors

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #982.

Summary

The US PUF tax-detail QRF predicted each income item from the recipient's own survey value of that item. On the donor side, each of those predictor columns was an alias of the donor's own imputed output, so the forest learned output = input. On the 2026-09-12 genuine-build inputs, PUF-clone wages had rank correlation 1.000 with survey wages. Among units with positive survey wages, 99.98% landed within 10% of the survey value. No clone exceeded the survey maximum, so ASEC topcodes and underreporting carried straight into the PUF half. This is the root of the missing $5M+ tail in #958.

This PR replaces the six survey income levels with these predictors, derived identically on both sides:

  • Demographics. Head age, spouse age (0 without a spouse), head sex and dependent count come from person age, sex and tax-unit role. The survey side reads its person table and the PUF side reads the processed PUF person arrays.
  • Income rank share. Each unit's weighted mid-rank of the six-item income total within its own population: the PUF-detail recipient rows on the survey side, the donor table on the PUF side.
  • Has earnings. Set when wages or self-employment income is nonzero. The PUF covers filers only, so without this flag the rank handed wages to 48% of survey non-earners.

Filing status and unit size stay. No income level is a predictor any more.

Evidence (pre-calibration)

The fit uses the saved 9/12 donor frame (211,677 tax units), predicted onto 231,007 PUF-clone recipients at 8 trees per variant. AGI is a proxy: the sum of 15 imputed items. Full receipts are in experiments/982-puf-self-prediction/.

Returns by proxy AGI Old New SOI Table 1.1 (TY2023)
$1.5M–2M 199,843 147,280 147,290
$2M–5M 200,741 213,125 203,229
$5M–10M 23,600 48,629 49,262
$10M+ 8,186 33,920 30,382
Wage measure Old New
Rank correlation with survey wages 1.000 0.906
Within 10% of survey value (units with positive survey wages) 99.98% 24.0%
Survey non-earner units given any earnings (of 67,908) 127 7
Wage-positive share (survey 67.7%) 67.7% 67.5%

Capital gains are 42.6% of $10M+ AGI under the new design, against 39.5% in SOI Table 1.4 and 0.1% under the old one.

Invariants

These hold for every input, and Hypothesis property tests in test_us_puf_income_rank_properties.py check them:

  • Every rank share lies strictly between 0 and 1 when weights are positive.
  • The weighted mean share is exactly one half.
  • A higher value never gets a larger share, and tied values share one rank.
  • Row order and weight scale do not change any rank, so clone copies that split a household's weight rank identically.
  • Rows with a missing value are excluded and do not move anyone else's rank.
  • The vectorized rank equals the pairwise definition. This is the differential test.
  • The earnings flag is 1 exactly when a component is nonzero, and missing when any component is missing.

Structurally, no default predictor resolves to an imputed output column. test_default_predictors_never_resolve_to_an_imputed_output pins this so the self-prediction cannot come back.

Fail-closed demographics

The demographics refuse malformed input rather than filling it:

  • Survey side. A tax-unit role outside HEAD, SPOUSE and DEPENDENT raises. A missing role leaves that unit's demographics missing under the strict policy, so the completeness check names it, and raises under the legacy policy.
  • PUF side. A missing age, sex or role flag raises, as does a person with no role, is_female and is_male that disagree, or a unit without a head. The processed PUF has none of these: 499,045 persons, no missing values, one role each, one head per unit. Neither does the 9/12 survey frame.

Tests

  • New predictor tests. test_us_puf_income_rank_predictors.py covers the mid-rank, demographics, donor construction from PUF arrays, recipient-row scope, the strict role check, and two end-to-end discriminating cases:
    • Topcoded survey units receive PUF wages above the topcode, and the old predictors do not.
    • Survey retirees receive no wages with the flag and do receive them without it.
  • Existing tests updated. Predictor counts and fixtures were updated, and the US spec was regenerated and re-pinned on the merge with main: predictor tuples, resource semantics, checkpoint identity, seed protocol and map, and coverage evidence.

Not in this PR

axiom: n/a: microsimulation data imputation, no policy rule changes.

🤖 Generated with Claude Code

MaxGhenis and others added 9 commits September 22, 2026 22:46
Replace the six self-predicting income predictors with head age, spouse
age, head sex, dependent count and the unit's weighted income rank in its
own population. Tests, spec regeneration and pins follow.

Refs #982

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

Evaluated on the 2026-09-12 genuine-build QRF inputs: ranking each unit at
the midpoint of its weight span, on the same six income items on both sides,
fits SOI Table 1.1 counts in the $1.5M-$10M bands within 1% before
calibration and puts $10M+ capital gains at 39.7% of AGI (SOI 39.5%).
The recipient rank now covers only the PUF-detail clone rows, so other spines
or clone channels in the frame cannot shift it. A donor table that cannot
supply a rank component leaves the predictor absent, so the missing-donor
check names it.

Refs #982

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The primary PUF predictor block now lists the five new predictors in place
of the six survey income levels. Recomputed from the regenerated bundle:
field-usage counts and the predictor-block claim, the primary predictor
tuple, resource semantics, seed protocol and map, and full checkpoint
digests, the pointer inventory digest, and the coverage evidence.

Tests: new test_us_puf_income_rank_predictors.py pins the rank midpoint,
demographics, donor construction, recipient-row scope, the strict role
check, and an end-to-end case where topcoded survey units receive PUF
wages above the topcode while the self-predicting design does not.

Refs #982

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebuilt from the session harness that a reboot wiped. Fits the production
QRF on the saved 2026-09-12 donor frame and predicts onto the saved PUF-clone
recipients per predictor variant; the scorer compares against SOI Tables 1.1
and 1.4, Colorado (#940), and audits wage self-prediction and participation.

Refs #982

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PUF covers filers, so the survey's zero-earnings group ranks level with
low but positive PUF incomes; with rank alone, 48% of survey units with no
wages or self-employment income drew PUF wages (8-tree offline run on the
2026-09-12 inputs). A has-earnings flag, taken from the donor's own wage and
self-employment outputs and the recipient's survey amounts, cuts that to 7 of
67,908 units and leaves the SOI Table 1.1 fit intact. The flag fixes only
participation, never a level.

Adds property tests (Hypothesis) for the mid-rank share: bounds, weighted
mean one half, monotonicity, tie and permutation invariance, weight-scale
invariance, missing-row exclusion, and a differential test against the
pairwise definition. Corrects a comment that said every clone landed within
10% of its survey wages; the measured share was 99.98%.

Refs #982

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

# Conflicts:
#	docs/evidence/spec-engine/us-f0-coverage.json
#	packages/microcosm-build/src/microcosm/build/spec_engine/field_usage.py
#	packages/microcosm-build/src/microcosm/build/spec_engine/inventory_coverage.py
#	packages/microcosm-build/tests/test_us_multispine_pool.py
#	tools/spec_engine_coverage.py
Adds puf_predictor_has_earnings to the primary PUF predictor block. Against
main the block swaps six survey income levels for six new predictors, so
field counts and the predictor-block claim hold; recomputed from observed
values: primary predictor tuples, resource semantics, full checkpoint
identity, seed protocol and seed map digests, and the coverage evidence.

Refs #982

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four variants at 8 trees on the 2026-09-12 inputs: the old self-predicting
design, pooled rank, pooled rank plus the earnings flag (chosen), and rank
within earnings groups plus the flag. README states the SOI band fit, the
wage self-prediction and participation audit, and the Colorado limit (#940).

Refs #982

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #982

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits September 25, 2026 22:05
Review findings (independent Astra review of #1033):
- Survey side: a tax-unit role outside HEAD/SPOUSE/DEPENDENT now raises;
  a missing role leaves that unit's demographics missing under the strict
  policy and raises under the legacy policy, instead of being ignored.
- PUF side: person arrays keep missing values instead of zero-filling them,
  and a missing age, sex or role flag, a person with no role, is_female and
  is_male that disagree, or a unit without a head now raises by name. The
  processed PUF has none of these (checked: 499,045 persons, no NaN, one role
  each, one head per unit), nor does the 9/12 survey frame.
- Evidence: the scorer now reports survey non-earners given any earnings
  (the old design gave earnings to 127 of 67,908, not 0), the Colorado top
  record, and inputs and run metadata; the within-10% figures state their
  denominator (units with positive survey wages); the unreceipted 32-tree
  claim is removed; the changelog no longer promises zero earnings.

Re-pins the seed protocol and seed map digests, the US bundle digest and the
loader golden, which hash puf_support.py source bytes.

Refs #982

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop the duplicate _TAX_UNIT_ROLES declaration; say the earnings flag
reduces earnings assignments to survey non-earners (7 of 67,908 in the
offline evaluation) rather than preventing them. Re-pins the seed protocol
and seed map digests, the US bundle digest and the loader golden.

Refs #982

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

Copy link
Copy Markdown
Contributor Author

Independent review by GPT-6 Astra through Subfleet, read-only.

Round 1 (head 4a49cfcbe) requested changes. It raised two major findings:

  • Unrecognized survey roles were silently ignored.
  • Missing PUF demographics were zero-filled, so a missing sex became male or female depending on the array.

It also raised three minor findings about evidence accuracy. ddd5f6e38 fixes all five, as listed in the commit message and the PR description.

Round 2 (head ddd5f6e38) approved, with all five findings verified resolved. It raised two low-priority cleanups:

  • A comment overstated that the earnings flag prevents earnings for non-earners.
  • A role constant was declared twice.

8ea1d24f2 applies both cleanups and re-pins the digests that hash puf_support.py source bytes: the seed protocol and seed map, the US bundle digest, and the loader golden. It makes no other change.

CI on 8ea1d24 failed four test_us_multispine_pool tests in the us-am,
fast-rest and wheels lanes: the hand-built ACS fixture had no
tax_unit_role_input column, so the fail-closed demographics refused its two
people. Production ACS spines carry the role from assign_us_unit_structure,
so the fixture now does too (each person heads a one-person unit). The
missing-role error now names the affected tax units.

Re-pins the seed protocol and seed map digests, the US bundle digest and the
loader golden (puf_support.py source bytes).

Refs #982

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

Copy link
Copy Markdown
Contributor Author

CI on 8ea1d24f2 failed four pool tests (test_us_multispine_pool.py) in the us-am, fast-rest and wheels lanes. The failures had one cause: the hand-built ACS fixture had no tax_unit_role_input column, and the fail-closed demographics added in round 1 correctly refused its two role-less people. Production ACS spines get the role from assign_us_unit_structure. 57216feeb gives the fixture that role, makes the error name the affected tax units, and re-pins the source-hashed digests. The four tests pass locally. There is no change to imputation logic since the approved head.

@MaxGhenis
MaxGhenis merged commit 310842b into main Sep 26, 2026
24 checks passed
@MaxGhenis
MaxGhenis deleted the us-982-puf-demographic-rank-predictors branch September 26, 2026 05:14
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Correction to my earlier numbers. The top-tail figures I posted weighted the PUF-clone half ×2, "as if this half were the whole population", and compared that with SOI. The real pool gives the survey half the other 50% of the weight, and the survey half has no units at $5M or more. Both halves at frame weight, before calibration and before main's capital-gains tail stage (8 trees, 15-item proxy AGI):

Band Old design #1033 SOI Table 1.1
$1M–1.5M returns 701k (1.90×) 483k (1.31×) 369k
$5M–10M returns 11.8k (0.24×) 24.3k (0.49×) 49.3k
$10M+ returns 4.1k (0.13×) 17.0k (0.56×) 30.4k
$10M+ AGI $92B $519B $908B
Colorado income above $1M $20.2B $66.3B (2.1×) $31.7B

#1033 roughly quadruples the pool's mass at $5M+, but on its own it reaches about half of SOI there, not SOI. Colorado is about 2× SOI above $1M, not 4×. One record still carries 81% of that. The script and output are at ~/PolicyEngine/_recovered/lane958-958-964-982-archive-20260926/982/actual_pool_bands.{py,json}. A follow-up will add them to experiments/982-puf-self-prediction/.

MaxGhenis added a commit that referenced this pull request Sep 26, 2026
Brings in the 19 main merges since 6442030: the Route A remediation stack
(#1016, #1018, #1017, #1025, #1024, #1028), #1029, #1005, #1008, #1004, #992,
#1031, #1015, #1033, #994, #954, #966, #1006 and #1010.

Eight conflicted paths, each recorded in docs/native-integration-20260923.md:
- test.yml keeps the native sharded matrix (main's --durations=25 is already
  in every native pytest call);
- the us_runtime facade stays lazy and gains main's three fiscal-target
  exclusion exports (the parent union, 920 names; the facade-union test is
  re-pinned to that union's digest);
- reform_validation.py keeps main's _released_engine_state and the native
  explicit-constructor default_simulate_factory;
- build_us_fiscal_refresh_release.py carries the native explicit consumer
  seams (formula metadata, dataset and microsimulation constructors, SPM
  selection) onto main's household-batched post-export scorer and batched
  base materialization; omitted seams keep main's exact calls, and _main
  supplies none. Three native test files that addressed the removed
  frame-based factory now address the scorer with the same assertions;
- identity pins observed on the merged tree: five EXPECTED_HASHES entries,
  the regenerated F0 coverage report (42,239/42,239 fields, 41/41 checks),
  the US spec identity 2dfa51b8... and the loader golden f2047cb9...

tools/generate_us_bundle_from_constants.py --check, tools/ci_test_groups.py
--verify, the CI matrix contract and ruff pass. The fiscal consumer,
formula-metadata, shared target/solve and calibration-attachment files pass;
the rest of the fiscal battery and the known pre-existing failures are
follow-up work on this branch.

No actual-data native run, release certification or publication.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

US PUF imputation predicts each income item from the recipient's own survey value, so the PUF clone half reproduces ASEC income

1 participant