Skip to content

Add Census state SPM poverty-rate backtests (104 out-of-sample validation rows) - #348

Merged
MaxGhenis merged 1 commit into
PolicyEngine:mainfrom
DTrim99:state-spm-poverty-validation
Jul 8, 2026
Merged

Add Census state SPM poverty-rate backtests (104 out-of-sample validation rows)#348
MaxGhenis merged 1 commit into
PolicyEngine:mainfrom
DTrim99:state-spm-poverty-validation

Conversation

@DTrim99

@DTrim99 DTrim99 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #295, motivated by the populace-vs-ECPS parity finding: the two datasets' state poverty levels diverge by up to 11.7pp, and nothing in the validation surface would have caught it — poverty appears nowhere in the calibration targets or the reform-validation payload. This adds the missing anchor.

What's new

  • statistic: "rate" on BaselineLevelSpec: weighted person-level share of a boolean variable (in_poverty), optionally restricted to a subpopulation (mask_variable: is_child) and sliced to a state. State slicing uses the numeric state_fips broadcast to persons (the string state_code_str can't cross entities), reusing STATE_FIPS_TO_POSTAL from fiscal_targets.
  • unit field on level rows ("percent" for rates, "currency-USD" otherwise) so the dashboard can format rates; a small calibration-diagnostics PR will consume it (currently rates would render as $0.13 — transitional).
  • us/state_spm_poverty_levels.json: 104 rows — overall + child SPM poverty for all 51 jurisdictions vs Census P60-287 2022–2024 three-year averages (overall table, by-age table), plus two national single-year-2024 rows (12.9% overall / 13.4% child, P60-287).

Out-of-sample status

Poverty is not a calibration target. Per-state person/child counts are calibrated, which pins the rate denominators but not the numerators — noted in the config _comment along with the vintage mismatch (2024 sim vs 2022–2024 average) and small-state CPS MOE caveats.

Expected results (from the parity run, national file)

Populace's national rates already sit near the anchor (13.3% overall / 14.6% child vs Census 12.9% / 13.4%), but state rows will show real spread — e.g. AL child 11.8% vs 14.6% benchmark (−2.8pp), CA child 22.8% vs 18.2% (+4.6pp), TX 15.8% vs 15.8%. That per-state error surface is exactly what the dashboard should be showing.

Tests

42 pass in test_reform_validation.py (rate math on a fake person sim incl. state slicing and child mask, spec validation, shipped-config well-formedness: 104 unique ids, 51 states, plausible rates, census.gov sources) + the country-package declaration contract.

🤖 Generated with Claude Code

…evels)

BaselineLevelSpec gains statistic="rate" (weighted person-level share of a
boolean variable, optionally restricted by mask_variable and sliced by
state via the numeric state_fips broadcast) and level rows now carry a
unit field ("percent" for rates, "currency-USD" otherwise) so the
dashboard can format them.

The shipped config compares the baseline's SPM poverty and child-poverty
rates for all 51 jurisdictions (plus two national single-year-2024 rows)
against Census P60-287 2022-2024 three-year averages. Poverty appears
nowhere in the calibration target surface, so every row is genuinely
out-of-sample; per-state person/child counts ARE calibrated, pinning the
denominators but not the rates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@MaxGhenis MaxGhenis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Independent verification — approve

Verified the shipped data, the rate math, and the anchoring measure independently of the earlier conductor pass. Everything checks out.

Data: all 104 rows verified against the cited sources

Parsed the two cited Census P60-287 XLSX tables directly (spm_opm_state.xlsx, spm_opm_state_by_age.xlsx) and compared every shipped benchmark:

  • 102 state rows match the tables exactly, to within 1-decimal rounding — 0 mismatches across all 51 jurisdictions × (overall, child). Spot examples: AK overall 10.4% (0.104), AL child 14.6% (0.146), CA child 18.2% (0.182), DC overall 15.3% (0.153). State rows correctly use the SPM Percent column (the 3-year 2022–2024 average), not the official-measure column.
  • The 2 national rows use single-year 2024, matching the P60-287 publication text: "The estimated SPM rate in 2024 was 12.9 percent" (0.129) and 13.4 percent for children (0.134). The vintage split — 3-year averages for states (single-year CPS state samples are too noisy), single-year for the nation — is the right call and is documented in the config _comment.

Rate math is correct and anchored to the right measure

  • in_poverty is genuinely the SPM indicator: it is an SPMUnit variable equal to poverty_gap > 0, where poverty_gap = max(spm_unit_spm_threshold − spm_unit_net_income, 0). So the backtest anchors SPM to SPM, not to the official measure.
  • _person_rate computes the weighted person share correctly: in_poverty broadcast to persons (map_to="person") gives the Census SPM-rate definition (share of persons in poverty), the is_child mask is a Person variable, and the state slice uses numeric state_fips (a Household int, default 6) broadcast to persons — matching int(fips) from STATE_FIPS_TO_POSTAL. The string state_code_str genuinely can't cross entities, so the numeric route is the correct one.
  • The hand-computed test_rate_level_computes_state_sliced_person_share case (4 persons, two states, child mask) pins the arithmetic: AL overall 10/40 = 0.25, CA overall 1.0, US child 30/60 = 0.5, AL child 10/40 = 0.25. All correct.

Tests and packaging

  • Coverage is solid: rate arithmetic, spec-validation errors (unknown statistic, multi-variable rate, cap_variable on a rate), and a shipped-config contract asserting 104 rows, 51 states, unique ids, plausible rate bounds, and census.gov sources.
  • state_spm_poverty_levels.json is registered in country_package.json resources (and the resource list is re-sorted + gets its trailing newline), so it packages and the declaration-contract test holds.
  • CI is fully green: test (3.13), test (3.14), wheels (3.13), wheels (3.14) all pass.

On the out-of-sample framing

The framing is exactly right and worth ratifying: poverty appears nowhere in the calibration target surface, so these are true out-of-sample diagnostics. Per-state person and child counts are calibrated (pinning the rate denominators, not the numerators), which is what makes the per-state error surface — AL child −2.8pp, CA child +4.6pp, TX child on the nose — informative rather than circular. Poverty must never become a calibration target; encoding it as a diagnostic anchor rather than a fit target is the correct design.

One transitional note (non-blocking)

Rate rows reuse the budget_effect field to carry a share (0.13), which the current dashboard would render as $0.13 until the companion calibration-diagnostics PR consumes the new unit: "percent" field. This is purely additive and self-flagged in the PR; the unit field is already emitted, so the fix is a downstream consumption change, not a data change.

Approving and merging.

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.

2 participants