Skip to content

Calibrate retirement contributions: targets, SS reconciliation, and QRF imputation#554

Draft
PavelMakarchuk wants to merge 18 commits intomainfrom
calibrate-retirement-contributions
Draft

Calibrate retirement contributions: targets, SS reconciliation, and QRF imputation#554
PavelMakarchuk wants to merge 18 commits intomainfrom
calibrate-retirement-contributions

Conversation

@PavelMakarchuk
Copy link
Collaborator

@PavelMakarchuk PavelMakarchuk commented Feb 25, 2026

Summary

Combines three related pieces of work for retirement contribution calibration (issue #561):

1. Calibration targets (original PR #554)

  • Correct traditional IRA target from $25B to $13.2B (SOI 1304)
  • Split 401(k) into traditional $482.7B and Roth $85.2B (BEA/FRED × Vanguard share)
  • Add Roth IRA target at $35.0B (SOI Tables 5 & 6)
  • Add self-employed pension ALD target at $29.5B (SOI 1304)
  • Fix RETCB_VAL allocation to use proportional split (traditional/Roth 401k + Roth IRA)

2. SS sub-component reconciliation (from PR #552)

  • After PUF imputation overwrites social_security_retirement, reconcile DI/survivor/dependent sub-components
  • QRF-based share prediction with age-heuristic fallback
  • Drop formula variables from the dataset to avoid stale values

3. QRF imputation for retirement contributions on PUF clone half (new)

Problem: When building the Extended CPS, PUF clone records get PUF-imputed income via QRF, but retirement contribution variables were just blindly duplicated from the CPS donor. This means a PUF clone with $0 wages could have $50k in 401(k) contributions — there's no model linking contributions to income.

Solution: Train a QRF on the CPS half (which has realistic income↔contribution relationships) and predict retirement contributions onto the PUF clone half using PUF-imputed income as input. Follows the exact pattern of the existing _impute_weeks_unemployed().

Variables modeled:

  • traditional_401k_contributions
  • roth_401k_contributions
  • traditional_ira_contributions
  • roth_ira_contributions
  • self_employed_pension_contributions

Predictors (13 total):

  • Demographics (from CPS sim): age, is_male, tax_unit_is_joint, tax_unit_count_dependents, is_tax_unit_head, is_tax_unit_spouse, is_tax_unit_dependent
  • Income (from PUF imputations): employment_income, self_employment_income, taxable_interest_income, qualified_dividend_income, taxable_pension_income, social_security

Post-prediction constraints:

  • Non-negativity on all values
  • 401(k) capped at year-specific limit ($23K + $7.5K catch-up for age ≥ 50 in 2024)
  • IRA capped at year-specific limit ($7K + $1K catch-up for age ≥ 50 in 2024)
  • 401(k) zeroed out for records with $0 employment income
  • SE pension zeroed out for records with $0 self-employment income

Data flow: CPS half keeps original values; PUF half gets CPS-trained QRF predictions. The traditional_ira_contributions was moved out of IMPUTED_VARIABLES (PUF-based QRF) into the CPS-trained model for consistency with the other retirement variables.

Test plan

  • 36 unit tests for retirement QRF imputation (constants, limits, constraints, routing, predictor validation)
  • 12 existing puf_impute tests pass
  • SS reconciliation tests pass
  • All tests passing locally (48/48 relevant tests)
  • Full Extended CPS build on Modal to verify aggregate weighted totals against calibration targets

Validation

A post-build validation script is provided at validation/validate_retirement_imputation.py. After a full build, run:

python validation/validate_retirement_imputation.py

It checks:

  1. Hard constraints: no negative values, no cap violations, $0 contributions for $0 income records
  2. Aggregate comparisons: weighted sums vs calibration targets (traditional 401k $482.7B, Roth 401k $85.2B, traditional IRA $13.2B, Roth IRA $35.0B, SE pension $29.5B)
  3. CPS vs PUF half distributions: mean values and participation rates per half

Files changed

File Change
policyengine_us_data/calibration/puf_impute.py Core implementation: constants, _get_retirement_limits(), _impute_retirement_contributions(), routing in puf_clone_dataset()
policyengine_us_data/tests/test_calibration/test_retirement_imputation.py 36 comprehensive tests
validation/validate_retirement_imputation.py Post-build validation script
conftest.py (root) Mock microimpute for Python < 3.12 test environments
policyengine_us_data/tests/test_calibration/conftest.py Same mock for calibration test directory

Closes #561

🤖 Generated with Claude Code

MaxGhenis and others added 8 commits February 24, 2026 21:18
When PUF imputation replaces social_security values, the sub-components
(retirement, disability, survivors, dependents) were left unchanged,
creating a mismatch. This caused a base-year discontinuity where projected
years had ~3x more SS recipients than the base year, producing artificial
9-point Gini swings.

The new reconcile_ss_subcomponents() function rescales sub-components
proportionally after PUF imputation. New recipients (CPS had zero SS
but PUF imputed positive) default to retirement.

Fixes #551

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New PUF recipients (CPS had zero SS) now get assigned based on age:
>= 62 -> retirement, < 62 -> disability. Matches the CPS fallback
logic. Falls back to retirement if age is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of the simple age >= 62 heuristic, train a QRF on CPS records
(where the reason-code split is known) to predict shares for new PUF
recipients. Uses age, gender, marital status, and other demographics.
Falls back to age heuristic when microimpute is unavailable or
training data is insufficient (< 100 records).

14 tests covering:
- Proportional rescaling (existing recipients)
- Age heuristic fallback (4 tests)
- QRF share prediction (4 tests including sum-to-one and
  elderly-predicted-as-retirement)
- Edge cases (zero imputation, missing subs, no SS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
CPS-PUF link is statistical (not identity-based), so the paired CPS
record's sub-component split is just one noisy draw. A QRF trained
on all CPS SS recipients gives a better expected prediction. Also
removes unnecessary try/except ImportError guard for microimpute.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fixes CI failure: DE TANF deficit_rate parameter started at 2024-10-01
in 1.570.7, causing ParameterNotFoundError for Jan 2024 simulations.
Fixed in newer releases (start date corrected to 2011-10-01).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Correct traditional_ira_contributions: $25B → $13.2B (SOI 1304
  Table 1.4 actual deduction, not total contributions)
- Add traditional_401k_contributions: $567.9B (BEA/FRED employee
  DC contributions)
- Add self_employed_pension_contribution_ald: $29.5B (SOI 1304
  Table 1.4 Keogh plan deduction)
- Remove roth_ira_contributions: structurally $0 due to CPS
  allocation bug (#553)
- Update both loss.py and etl_national_targets.py

Closes #553

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace sequential waterfall with proportional allocation based on
administrative data. The old waterfall gave 401(k) first priority,
consuming all of RETCB_VAL and leaving IRA contributions at $0 for
every record. The Roth IRA allocation was also mathematically
guaranteed to produce $0.

The new approach splits RETCB_VAL proportionally:
- DC vs IRA: 90.8% / 9.2% (BEA/FRED vs IRS SOI)
- Within DC: 85% traditional / 15% Roth (Vanguard/PSCA)
- Within IRA: 39.2% traditional / 60.8% Roth (IRS SOI Tables 5 & 6)

All fractions are stored in imputation_parameters.yaml with sources.
Contribution limits are still enforced.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PavelMakarchuk and others added 3 commits February 25, 2026 18:25
- Split 401(k) target: $567.9B total employee DC deferrals (BEA/FRED)
  into traditional $482.7B (85%) and Roth $85.2B (15%) using Vanguard
  How America Saves 2024 dollar share estimate
- Add roth_ira_contributions target: $35.0B from IRS SOI Accumulation
  Tables 5 & 6 (TY 2022) — direct administrative source
- Update etl_national_targets.py in parallel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fixes CI failure caused by DE TANF deficit_rate parameter missing
history before 2024-10-01. The fix was merged in policyengine-us
PR #7170 but uv.lock was pinned at 1.570.7.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Variables with formulas in policyengine-us are recomputed by the
simulation engine, so storing them wastes space and can mislead
validation. This removes 9 such variables from the extended CPS
output (saving ~15MB).

Also adds tests verifying:
- No formula variables are stored (except person_id)
- Stored input values match what the simulation computes
- SS sub-components sum to total social_security per person

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@PavelMakarchuk PavelMakarchuk marked this pull request as ready for review February 26, 2026 01:54
@PavelMakarchuk PavelMakarchuk requested review from MaxGhenis and baogorek and removed request for baogorek February 26, 2026 01:54
MaxGhenis and others added 4 commits February 25, 2026 20:58
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Update _drop_formula_variables and tests to catch variables that use
`adds` or `subtracts` (e.g. social_security), not just explicit
formulas. These are also recomputed by the simulation engine.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Collaborator

@baogorek baogorek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, just please update this one bullet point in the PR body (if this is indeed correct):

Says:

  • Remove roth_ira_contributions target ($39B) — structurally $0 due to CPS allocation bug where traditional IRA always exhausts the IRA limit first

But:
code actually keeps roth_ira_contributions and updates it from $39B to $35.0B in both loss.py and etl_national_targets.py (which it can do be

PavelMakarchuk and others added 2 commits February 26, 2026 18:01
PUF clones previously copied CPS retirement contributions blindly,
so a record with $0 wages could have $50k in 401(k) contributions.

Train a QRF on CPS data (which has realistic income-to-contribution
relationships) and predict onto the PUF half using PUF-imputed income.
Post-prediction constraints enforce contribution caps, zero-out rules
for records with no wages/SE income, and non-negativity.

- Remove traditional_ira_contributions from IMPUTED_VARIABLES
- Add CPS_RETIREMENT_VARIABLES, RETIREMENT_PREDICTORS constants
- Add _get_retirement_limits() with year-specific 401k/IRA caps
- Add _impute_retirement_contributions() following _impute_weeks_unemployed pattern
- Integrate into puf_clone_dataset() variable routing loop
- Add 34 unit tests covering constraints, routing, and limits

Closes #561

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@PavelMakarchuk PavelMakarchuk changed the title Fix and add retirement contribution calibration targets Calibrate retirement contributions: targets, SS reconciliation, and QRF imputation Feb 26, 2026
@PavelMakarchuk PavelMakarchuk marked this pull request as draft February 26, 2026 23:08
Add income predictors (interest, dividends, pension, SS) to the
retirement contribution QRF, matching issue #561's recommendation.
Split RETIREMENT_PREDICTORS into demographic and income sublists so
the test side correctly sources income from PUF imputations.

Also add validation/validate_retirement_imputation.py for post-build
constraint checking and aggregate comparison against calibration targets.

Co-Authored-By: Claude Opus 4.6 <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.

Train models for CPS-only variables on PUF clone half

3 participants