Skip to content

Commit 222cbfd

Browse files
authored
Use modeled Medicare Part B inputs and targets (#714)
* Use modeled Medicare Part B inputs and targets * Fix Medicare Part B changelog fragment * Fix Medicare Part B data compatibility * Format Part B data compatibility changes * Populate Medicare enrollment independently of reported premiums
1 parent f702689 commit 222cbfd

10 files changed

Lines changed: 143 additions & 7 deletions

File tree

changelog.d/713.fixed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Modeled Medicare Part B premiums from enrollment and premium schedules, netted a cycle-free MSP standard-premium offset, and documented the national Part B calibration target as an approximate beneficiary-paid out-of-pocket benchmark rather than gross CMS premium income.

policyengine_us_data/datasets/cps/cps.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@
3737
assign_takeup_with_reported_anchors,
3838
reported_subsidized_marketplace_by_tax_unit,
3939
)
40+
from policyengine_us_data.utils.policyengine import (
41+
supports_medicare_enrollment_input,
42+
supports_modeled_medicare_part_b_inputs,
43+
)
4044

4145

4246
CURRENT_HEALTH_COVERAGE_REPORTED_VAR_MAP = {
@@ -820,7 +824,12 @@ def add_personal_income_variables(cps: h5py.File, person: DataFrame, year: int):
820824
cps["health_insurance_premiums_without_medicare_part_b"] = person.PHIP_VAL
821825
cps["over_the_counter_health_expenses"] = person.POTC_VAL
822826
cps["other_medical_expenses"] = person.PMED_VAL
823-
cps["medicare_part_b_premiums"] = person.PEMCPREM
827+
if supports_medicare_enrollment_input():
828+
cps["medicare_enrolled"] = person.MCARE == 1
829+
if supports_modeled_medicare_part_b_inputs():
830+
cps["medicare_part_b_premiums_reported"] = person.PEMCPREM
831+
else:
832+
cps["medicare_part_b_premiums"] = person.PEMCPREM
824833

825834
# Get QBI simulation parameters ---
826835
yamlfilename = (

policyengine_us_data/datasets/cps/extended_cps.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
impute_tax_unit_mortgage_balance_hints,
2020
)
2121
from policyengine_us_data.utils.policyengine import has_policyengine_us_variables
22+
from policyengine_us_data.utils.policyengine import (
23+
supports_modeled_medicare_part_b_inputs,
24+
)
2225
from policyengine_us_data.utils.retirement_limits import (
2326
get_retirement_limits,
2427
get_se_pension_limits,
@@ -150,7 +153,6 @@ def _supports_structural_mortgage_inputs() -> bool:
150153
"health_insurance_premiums_without_medicare_part_b",
151154
"over_the_counter_health_expenses",
152155
"other_medical_expenses",
153-
"medicare_part_b_premiums",
154156
"child_support_expense",
155157
# Hours/employment
156158
"weekly_hours_worked",
@@ -164,6 +166,9 @@ def _supports_structural_mortgage_inputs() -> bool:
164166
"self_employment_income_last_year",
165167
]
166168

169+
if not supports_modeled_medicare_part_b_inputs():
170+
CPS_ONLY_IMPUTED_VARIABLES.append("medicare_part_b_premiums")
171+
167172
# Set for O(1) lookup in the splice loop.
168173
_CPS_ONLY_SET = set(CPS_ONLY_IMPUTED_VARIABLES)
169174

policyengine_us_data/datasets/puf/puf.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -802,11 +802,13 @@ class PUF_2024(PUF):
802802
url = "release://policyengine/irs-soi-puf/1.8.0/puf_2024.h5"
803803

804804

805+
# Leave Medicare Part B out of the generic PUF medical-expense split:
806+
# the baseline model now derives Part B premiums separately.
805807
MEDICAL_EXPENSE_CATEGORY_BREAKDOWNS = {
806808
"health_insurance_premiums_without_medicare_part_b": 0.453,
807809
"other_medical_expenses": 0.325,
808-
"medicare_part_b_premiums": 0.137,
809810
"over_the_counter_health_expenses": 0.085,
811+
"medicare_part_b_premiums": 0.137,
810812
}
811813

812814
if __name__ == "__main__":

policyengine_us_data/db/etl_national_targets.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
from policyengine_us_data.storage.calibration_targets.soi_metadata import (
1313
RETIREMENT_CONTRIBUTION_TARGETS,
1414
)
15+
from policyengine_us_data.utils.cms_medicare import (
16+
get_beneficiary_paid_medicare_part_b_premiums_notes,
17+
get_beneficiary_paid_medicare_part_b_premiums_source,
18+
get_beneficiary_paid_medicare_part_b_premiums_target,
19+
)
1520
from policyengine_us_data.utils.db import (
1621
DEFAULT_YEAR,
1722
etl_argparser,
@@ -152,9 +157,15 @@ def extract_national_targets(year: int = DEFAULT_YEAR):
152157
},
153158
{
154159
"variable": "medicare_part_b_premiums",
155-
"value": 112e9,
156-
"source": "CMS Medicare data",
157-
"notes": "Medicare Part B premium payments",
160+
"value": get_beneficiary_paid_medicare_part_b_premiums_target(
161+
HARDCODED_YEAR
162+
),
163+
"source": get_beneficiary_paid_medicare_part_b_premiums_source(
164+
HARDCODED_YEAR
165+
),
166+
"notes": get_beneficiary_paid_medicare_part_b_premiums_notes(
167+
HARDCODED_YEAR
168+
),
158169
"year": HARDCODED_YEAR,
159170
},
160171
{
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
MEDICARE_PART_B_GROSS_PREMIUM_INCOME = {
2+
2024: 139.837e9,
3+
}
4+
5+
6+
MEDICARE_STATE_BUY_IN_MINIMUM_BENEFICIARIES = {
7+
2024: 10_000_000,
8+
}
9+
10+
11+
BENEFICIARY_PAID_MEDICARE_PART_B_PREMIUM_TARGETS = {
12+
2024: 112e9,
13+
}
14+
15+
16+
def get_beneficiary_paid_medicare_part_b_premiums_target(year: int) -> float:
17+
try:
18+
return BENEFICIARY_PAID_MEDICARE_PART_B_PREMIUM_TARGETS[year]
19+
except KeyError as exc:
20+
raise ValueError(
21+
f"No beneficiary-paid Medicare Part B premium target sourced for {year}."
22+
) from exc
23+
24+
25+
def get_beneficiary_paid_medicare_part_b_premiums_source(year: int) -> str:
26+
gross_income = MEDICARE_PART_B_GROSS_PREMIUM_INCOME[year] / 1e9
27+
minimum_buy_in = MEDICARE_STATE_BUY_IN_MINIMUM_BENEFICIARIES[year]
28+
return (
29+
"CMS 2025 Medicare Trustees Report Table III.C3 actual 2024 Part B "
30+
f"premium income (${gross_income:.3f}B), plus CMS State Buy-In FAQ "
31+
f"noting states paid Part B premiums for over {minimum_buy_in:,} people"
32+
)
33+
34+
35+
def get_beneficiary_paid_medicare_part_b_premiums_notes(year: int) -> str:
36+
return (
37+
"Approximate beneficiary-paid Medicare Part B out-of-pocket premiums "
38+
"for SPM/MOOP calibration. This intentionally does not target gross "
39+
"trust-fund premium income because Medicaid and other MSP pathways pay "
40+
"premiums on behalf of some enrollees."
41+
)

policyengine_us_data/utils/loss.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
from policyengine_us_data.storage.calibration_targets.soi_metadata import (
1313
RETIREMENT_CONTRIBUTION_TARGETS,
1414
)
15+
from policyengine_us_data.utils.cms_medicare import (
16+
get_beneficiary_paid_medicare_part_b_premiums_target,
17+
)
1518
from policyengine_us_data.db.etl_irs_soi import get_national_geography_soi_target
1619
from policyengine_core.reforms import Reform
1720
from policyengine_us_data.utils.soi import pe_to_soi, get_soi
@@ -26,7 +29,9 @@
2629
HARD_CODED_TOTALS = {
2730
"health_insurance_premiums_without_medicare_part_b": 385e9,
2831
"other_medical_expenses": 278e9,
29-
"medicare_part_b_premiums": 112e9,
32+
"medicare_part_b_premiums": get_beneficiary_paid_medicare_part_b_premiums_target(
33+
2024
34+
),
3035
"over_the_counter_health_expenses": 72e9,
3136
"spm_unit_spm_threshold": 3_945e9,
3237
"child_support_expense": 33e9,

policyengine_us_data/utils/policyengine.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,13 @@ def has_policyengine_us_variables(*variables: str) -> bool:
134134
return False
135135

136136
return set(variables).issubset(available_variables)
137+
138+
139+
def supports_medicare_enrollment_input() -> bool:
140+
return has_policyengine_us_variables("medicare_enrolled")
141+
142+
143+
def supports_modeled_medicare_part_b_inputs() -> bool:
144+
return has_policyengine_us_variables(
145+
"medicare_part_b_premiums_reported",
146+
)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import pytest
2+
3+
from policyengine_us_data.utils.cms_medicare import (
4+
get_beneficiary_paid_medicare_part_b_premiums_notes,
5+
get_beneficiary_paid_medicare_part_b_premiums_source,
6+
get_beneficiary_paid_medicare_part_b_premiums_target,
7+
)
8+
9+
10+
def test_beneficiary_paid_medicare_part_b_target_2024_is_sourced():
11+
assert get_beneficiary_paid_medicare_part_b_premiums_target(2024) == pytest.approx(
12+
112e9
13+
)
14+
15+
16+
def test_beneficiary_paid_medicare_part_b_source_mentions_primary_sources():
17+
source = get_beneficiary_paid_medicare_part_b_premiums_source(2024)
18+
assert "2025 Medicare Trustees Report" in source
19+
assert "State Buy-In FAQ" in source
20+
21+
22+
def test_beneficiary_paid_medicare_part_b_notes_describe_out_of_pocket_semantics():
23+
notes = get_beneficiary_paid_medicare_part_b_premiums_notes(2024)
24+
assert "out-of-pocket" in notes
25+
assert "gross trust-fund premium income" in notes
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from policyengine_us_data.datasets.cps.extended_cps import (
2+
CPS_ONLY_IMPUTED_VARIABLES,
3+
supports_modeled_medicare_part_b_inputs,
4+
)
5+
from policyengine_us_data.datasets.puf.puf import MEDICAL_EXPENSE_CATEGORY_BREAKDOWNS
6+
from policyengine_us_data.utils import policyengine as policyengine_utils
7+
8+
9+
def test_medicare_part_b_clone_imputation_matches_installed_model_support():
10+
assert ("medicare_part_b_premiums" in set(CPS_ONLY_IMPUTED_VARIABLES)) is (
11+
not supports_modeled_medicare_part_b_inputs()
12+
)
13+
14+
15+
def test_puf_medical_breakdown_still_sums_to_one():
16+
assert sum(MEDICAL_EXPENSE_CATEGORY_BREAKDOWNS.values()) == 1.0
17+
18+
19+
def test_supports_medicare_enrollment_input_allows_partial_support(monkeypatch):
20+
monkeypatch.setattr(
21+
policyengine_utils,
22+
"has_policyengine_us_variables",
23+
lambda *variables: variables == ("medicare_enrolled",),
24+
)
25+
26+
assert policyengine_utils.supports_medicare_enrollment_input() is True
27+
assert policyengine_utils.supports_modeled_medicare_part_b_inputs() is False

0 commit comments

Comments
 (0)