Repository navigation
US Medicare IRMAA: enrollee-gated Part B premium target, Part D take-up input, IRMAA validation rows, SOI age-by-AGI counts #1206
Description
Summary
PolicyEngine's Medicare IRMAA runs above the 2026 Medicare Trustees Report on the certified US dataset: Part B by 13% and Part D by 27.5% in 2026. Most of the gap is data. Three Microcosm changes, and one rule change already in a PE PR, address it. Waiting on Max's decision; do not build or ship until it is ruled.
The run used populace-us-2024-spm-20260915 on pe-us 2.2.1 with PolicyEngine/policyengine-us#10106 backported. Every figure is a weighted MicroSeries sum from a real microsimulation. Evidence, scripts and JSON are in ~/PolicyEngine/crfb-m4a/research/irmaa-run/diagnosis/ (REPORT.md).
Findings
| 2026 | Model | Trustees (V.E3 / V.E4) |
|---|---|---|
| Part B payers | 6.07M | 6.1M |
| Part B IRMAA | $21.27B | $18.8B |
| Part B per payer | $292/month | $257/month |
| Part D payers | 6.07M | 5.3M |
| Part D IRMAA | $3.95B | $3.1B |
-
Part D is charged to every Medicare enrollee.
takes_up_medicare_if_eligiblecomes from ASECMCARE == 1and covers Part A or Part B. Nothing in the data marks Part D enrollment. Charge the Part D income-related premium only to Part D enrollees policyengine-us#10115 adds the inputtakes_up_medicare_part_d_if_eligible(default true) and gates the surcharge on it. -
The tier mix is top-heavy because of the AGI tail (US top-income tail is missing above $5M of AGI: Table 1.1 size-of-AGI facts never bind, and the pool's tail stratum carries capital gains only #958). The payer count is right, but too many payers sit in tiers 4–5. Comparing heads aged 65 or older in 2024 against SOI TY2023 Table 1.6 returns, the file holds:
- 1.24x at $200K–500K;
- 1.87x at $500K–1M;
- 3.9x at $1M–1.5M.
-
The Part B payer match is two errors offsetting. A prototype reweighting of the certified file to SOI Table 1.1 for $100K–$5M (Bind the SOI Table 1.1 size-of-AGI distribution as calibration targets #960's method) moves Part B 2026:
- dollars to −8%;
- payers to −20%;
- per payer still +14%.
Adding the Table 1.6 65+ counts gives −13.6%, −21.5% and +10%.
-
The Part B premium target is mapped to the wrong variable.
fiscal_targets.pymaps CMS premiums-from-enrollees (CY2024, $139.837B, IRMAA included) togross_medicare_part_b_premium. That variable is defined foris_medicare_eligible, so it also counts eligibles who aren't enrolled. For 2024:- $128.23B on pe-us 2.2.1, where 2024 IRMAA is zero;
- $148.61B with #10106.
gross_medicare_part_b_premium_if_enrolled, documented in pe-us for this target, gives $137.65B with #10106 (−1.6%).
Proposal (each item needs Max's go)
-
Target mapping. Map
("cms_medicare", "actual_amount", "premiums_from_enrollees")togross_medicare_part_b_premium_if_enrolled. -
Part D take-up.
- After policyengine-us#10115 merges, produce
takes_up_medicare_part_d_if_eligiblefor Medicare enrollees. - Weighted Part D enrollment should match Trustees Table V.B3 Part D less Table IV.B7 retiree drug subsidy enrollees: about 57.4M in 2026.
- Condition the draw on a source with Part D status, such as MEPS or MCBS, rather than a flat rate.
- Then validate against the Part D payer count.
- After policyengine-us#10115 merges, produce
-
IRMAA validation rows (validation only). Trustees V.E3/V.E4 CY2024 actuals:
- Part B: 4.8M payers and $12.8B;
- Part D: 4.2M payers and $2.2B.
Measure on the release with Part B as
gross_medicare_part_b_premium_if_enrolled − base_part_b_premiumover Medicare enrollees, and Part D asincome_adjusted_part_d_premium_surcharge. Consider promoting them to calibration once Bind the SOI Table 1.1 size-of-AGI distribution as calibration targets #960/Carry PUF high-AGI donors into the own-tail stratum with their full income vector #964 land and the residual is known. -
SOI Table 1.6. Bind return counts by age × AGI size (at least the 65+ row, $100K and up) alongside Bind the SOI Table 1.1 size-of-AGI distribution as calibration targets #960's Table 1.1 rows. The source is
23in16ag.xls: counts only, with age taken from the primary taxpayer.
Not settled by these runs
- The Trustees' count basis (average month or ever-in-year).
- Partial-year enrollment and SSA-44 redeterminations.
- Income shape within SOI's coarse bands.
- Under-65 enrollees, who are missing because no
months_receiving_social_security_disabilityis carried (Benchmark build: carry the disability inputs the references depend on (SSI criterion, SSDI months, permanent-and-total disability) #876).
Activity
Report held-out Medicare IRMAA payers and premiums (#1206)
Re-pin the national_state surface version for the Part B remap (#1206)
Address review: withhold IRMAA comparisons with no payers; state the …
Withhold IRMAA comparisons per Part; tighten holdout wording (#1206)
MaxGhenis commentedon Oct 12, 2026
Change 2 (
takes_up_medicare_part_d_if_eligible): checked 2026-10-12 09:20 UTC, not started.The change waits on PolicyEngine/policyengine-us#10115, which is still open:
REVIEW_REQUIRED).0470a2567f: one failure, inFull Suite - Rest(run 38168682127). The failing test istest_no_group_variables_in_person_adds.py::test_known_violations_only_shrink, which asks to delete('il_aabd_expense_exemption_person', 'adds', 'state_withheld_income_tax')fromKNOWN_VIOLATIONS. pe-usmainhas already dropped that entry, so the failure comes from main moving on, not from the Part D change. A new run (38228102580) was queued at 09:09 UTC.Two Microcosm-side blockers remain after #10115 merges:
main(d1fda720b7) locks policyengine-us 2.2.1. That version defines neithertakes_up_medicare_part_d_if_eligible(#10115) nor the 2024 IRMAA fix (Fall back to the earliest input year's MAGI for Medicare IRMAA, and add a same-year income switch policyengine-us#10106, first released in 2.40.0). I found no open Microcosm PR that bumps the engine.US_STORED_NON_VARIABLE_COLUMNSentry. Without one, the stored-input refusal from Published US releases store would_claim_wic, which policyengine-us 2.x ignores, so WIC take-up is 100% under policyengine.py 6.1.1 #1026 stops it.Next step: once #10115 merges and ships in a release, pick this up as specified in d1372.
Merge pull request #1211 from PolicyEngine/irmaa-1206-targets
MaxGhenis commentedon Oct 12, 2026
Changes 1 and 3 of d1372 (approved by Max 2026-10-12) landed in #1211, merged as 1239276. Change 2 is a tracked follow-up, below. Change 4 goes with #960. No data was released.
1. Part B premium target remap (landed)
("cms_medicare", "actual_amount", "premiums_from_enrollees")now bindsgross_medicare_part_b_premium_if_enrolled(defined formedicare_enrolled) instead ofgross_medicare_part_b_premium(defined foris_medicare_eligible). Nothing else about the target changes. Thenational_statesurface version moves from f96be9f41781 to e1f9906bf0e0; counts and this target's loss weight are unchanged.Engine version. Microcosm locks policyengine-us 2.2.1, which doesn't include PolicyEngine/policyengine-us#10106. The first PyPI release with #10106 is 2.40.0 (uploaded 2026-10-12T06:49Z; checked in the wheel). Until Microcosm's engine moves to 2.40.0 or later, a 2024 release has no IRMAA, so this row reads the standard premium alone.
On the certified file in 2024, before calibration, from the diagnosis's real runs:
gross_medicare_part_b_premium..._if_enrolledSo until the engine bump, a release built with this mapping leans harder on Medicare-enrollee weights. That's noted on the release decision (d1318).
Out of reach for the model: the $139.837B target includes $14.410B paid by disabled enrollees under 65; enrollees aged 65 and over paid $125.427B. Source: 2025 Trustees Report Table III.C1, CY2024, PDF p. 86. The model has no Medicare enrollees under 65 (#876), so about 10.3% of the target is out of its reach. With #10106 the model's $137.65B is 9.7% above the 65-and-over row. Whether to target the 65-and-over row instead is queued for Max as d1410.
3. IRMAA validation rows (landed, validation only)
us_medicare_irmaa_holdout.jsonis written beside the SSI holdout. It is never a calibration row and never a release gate. It compares the model with the 2026 Medicare Trustees Report, CY2024 historical rows:How the model side is measured:
gross_medicare_part_b_premium_if_enrolled − base_part_b_premium, formedicare_enrolledpeople.no_payersand the comparisons are withheld rather than shown as −100%. This also applies Part by Part.Real runs on the certified H5:
no_payers(57.52M weighted enrollees, no payer).2. Part D take-up (tracked follow-up)
PolicyEngine/policyengine-us#10115 is still open (review required), so change 2 hasn't started. A follow-up chip carries the brief. What it needs to handle:
takes_up_medicare_part_d_if_eligibleon an engine that doesn't define it, so this also depends on Microcosm's engine bump.