diff --git a/changelog.d/713.fixed b/changelog.d/713.fixed new file mode 100644 index 000000000..5fea41f40 --- /dev/null +++ b/changelog.d/713.fixed @@ -0,0 +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. diff --git a/policyengine_us_data/datasets/cps/cps.py b/policyengine_us_data/datasets/cps/cps.py index b6a7f40a6..1d05e2ca6 100644 --- a/policyengine_us_data/datasets/cps/cps.py +++ b/policyengine_us_data/datasets/cps/cps.py @@ -37,6 +37,10 @@ assign_takeup_with_reported_anchors, reported_subsidized_marketplace_by_tax_unit, ) +from policyengine_us_data.utils.policyengine import ( + supports_medicare_enrollment_input, + supports_modeled_medicare_part_b_inputs, +) CURRENT_HEALTH_COVERAGE_REPORTED_VAR_MAP = { @@ -820,7 +824,12 @@ def add_personal_income_variables(cps: h5py.File, person: DataFrame, year: int): cps["health_insurance_premiums_without_medicare_part_b"] = person.PHIP_VAL cps["over_the_counter_health_expenses"] = person.POTC_VAL cps["other_medical_expenses"] = person.PMED_VAL - cps["medicare_part_b_premiums"] = person.PEMCPREM + if supports_medicare_enrollment_input(): + cps["medicare_enrolled"] = person.MCARE == 1 + if supports_modeled_medicare_part_b_inputs(): + cps["medicare_part_b_premiums_reported"] = person.PEMCPREM + else: + cps["medicare_part_b_premiums"] = person.PEMCPREM # Get QBI simulation parameters --- yamlfilename = ( diff --git a/policyengine_us_data/datasets/cps/extended_cps.py b/policyengine_us_data/datasets/cps/extended_cps.py index 8e01f5054..e5208d630 100644 --- a/policyengine_us_data/datasets/cps/extended_cps.py +++ b/policyengine_us_data/datasets/cps/extended_cps.py @@ -19,6 +19,9 @@ impute_tax_unit_mortgage_balance_hints, ) from policyengine_us_data.utils.policyengine import has_policyengine_us_variables +from policyengine_us_data.utils.policyengine import ( + supports_modeled_medicare_part_b_inputs, +) from policyengine_us_data.utils.retirement_limits import ( get_retirement_limits, get_se_pension_limits, @@ -150,7 +153,6 @@ def _supports_structural_mortgage_inputs() -> bool: "health_insurance_premiums_without_medicare_part_b", "over_the_counter_health_expenses", "other_medical_expenses", - "medicare_part_b_premiums", "child_support_expense", # Hours/employment "weekly_hours_worked", @@ -164,6 +166,9 @@ def _supports_structural_mortgage_inputs() -> bool: "self_employment_income_last_year", ] +if not supports_modeled_medicare_part_b_inputs(): + CPS_ONLY_IMPUTED_VARIABLES.append("medicare_part_b_premiums") + # Set for O(1) lookup in the splice loop. _CPS_ONLY_SET = set(CPS_ONLY_IMPUTED_VARIABLES) diff --git a/policyengine_us_data/datasets/puf/puf.py b/policyengine_us_data/datasets/puf/puf.py index bde0f33ff..2eba00911 100644 --- a/policyengine_us_data/datasets/puf/puf.py +++ b/policyengine_us_data/datasets/puf/puf.py @@ -802,11 +802,13 @@ class PUF_2024(PUF): url = "release://policyengine/irs-soi-puf/1.8.0/puf_2024.h5" +# Leave Medicare Part B out of the generic PUF medical-expense split: +# the baseline model now derives Part B premiums separately. MEDICAL_EXPENSE_CATEGORY_BREAKDOWNS = { "health_insurance_premiums_without_medicare_part_b": 0.453, "other_medical_expenses": 0.325, - "medicare_part_b_premiums": 0.137, "over_the_counter_health_expenses": 0.085, + "medicare_part_b_premiums": 0.137, } if __name__ == "__main__": diff --git a/policyengine_us_data/db/etl_national_targets.py b/policyengine_us_data/db/etl_national_targets.py index b6aaa5c2c..7747fe76b 100644 --- a/policyengine_us_data/db/etl_national_targets.py +++ b/policyengine_us_data/db/etl_national_targets.py @@ -12,6 +12,11 @@ from policyengine_us_data.storage.calibration_targets.soi_metadata import ( RETIREMENT_CONTRIBUTION_TARGETS, ) +from policyengine_us_data.utils.cms_medicare import ( + get_beneficiary_paid_medicare_part_b_premiums_notes, + get_beneficiary_paid_medicare_part_b_premiums_source, + get_beneficiary_paid_medicare_part_b_premiums_target, +) from policyengine_us_data.utils.db import ( DEFAULT_YEAR, etl_argparser, @@ -152,9 +157,15 @@ def extract_national_targets(year: int = DEFAULT_YEAR): }, { "variable": "medicare_part_b_premiums", - "value": 112e9, - "source": "CMS Medicare data", - "notes": "Medicare Part B premium payments", + "value": get_beneficiary_paid_medicare_part_b_premiums_target( + HARDCODED_YEAR + ), + "source": get_beneficiary_paid_medicare_part_b_premiums_source( + HARDCODED_YEAR + ), + "notes": get_beneficiary_paid_medicare_part_b_premiums_notes( + HARDCODED_YEAR + ), "year": HARDCODED_YEAR, }, { diff --git a/policyengine_us_data/utils/cms_medicare.py b/policyengine_us_data/utils/cms_medicare.py new file mode 100644 index 000000000..b710cbdea --- /dev/null +++ b/policyengine_us_data/utils/cms_medicare.py @@ -0,0 +1,41 @@ +MEDICARE_PART_B_GROSS_PREMIUM_INCOME = { + 2024: 139.837e9, +} + + +MEDICARE_STATE_BUY_IN_MINIMUM_BENEFICIARIES = { + 2024: 10_000_000, +} + + +BENEFICIARY_PAID_MEDICARE_PART_B_PREMIUM_TARGETS = { + 2024: 112e9, +} + + +def get_beneficiary_paid_medicare_part_b_premiums_target(year: int) -> float: + try: + return BENEFICIARY_PAID_MEDICARE_PART_B_PREMIUM_TARGETS[year] + except KeyError as exc: + raise ValueError( + f"No beneficiary-paid Medicare Part B premium target sourced for {year}." + ) from exc + + +def get_beneficiary_paid_medicare_part_b_premiums_source(year: int) -> str: + gross_income = MEDICARE_PART_B_GROSS_PREMIUM_INCOME[year] / 1e9 + minimum_buy_in = MEDICARE_STATE_BUY_IN_MINIMUM_BENEFICIARIES[year] + return ( + "CMS 2025 Medicare Trustees Report Table III.C3 actual 2024 Part B " + f"premium income (${gross_income:.3f}B), plus CMS State Buy-In FAQ " + f"noting states paid Part B premiums for over {minimum_buy_in:,} people" + ) + + +def get_beneficiary_paid_medicare_part_b_premiums_notes(year: int) -> str: + return ( + "Approximate beneficiary-paid Medicare Part B out-of-pocket premiums " + "for SPM/MOOP calibration. This intentionally does not target gross " + "trust-fund premium income because Medicaid and other MSP pathways pay " + "premiums on behalf of some enrollees." + ) diff --git a/policyengine_us_data/utils/loss.py b/policyengine_us_data/utils/loss.py index 26d3a5014..dceddb142 100644 --- a/policyengine_us_data/utils/loss.py +++ b/policyengine_us_data/utils/loss.py @@ -12,6 +12,9 @@ from policyengine_us_data.storage.calibration_targets.soi_metadata import ( RETIREMENT_CONTRIBUTION_TARGETS, ) +from policyengine_us_data.utils.cms_medicare import ( + get_beneficiary_paid_medicare_part_b_premiums_target, +) from policyengine_us_data.db.etl_irs_soi import get_national_geography_soi_target from policyengine_core.reforms import Reform from policyengine_us_data.utils.soi import pe_to_soi, get_soi @@ -26,7 +29,9 @@ HARD_CODED_TOTALS = { "health_insurance_premiums_without_medicare_part_b": 385e9, "other_medical_expenses": 278e9, - "medicare_part_b_premiums": 112e9, + "medicare_part_b_premiums": get_beneficiary_paid_medicare_part_b_premiums_target( + 2024 + ), "over_the_counter_health_expenses": 72e9, "spm_unit_spm_threshold": 3_945e9, "child_support_expense": 33e9, diff --git a/policyengine_us_data/utils/policyengine.py b/policyengine_us_data/utils/policyengine.py index 1d150ee97..869b95d9a 100644 --- a/policyengine_us_data/utils/policyengine.py +++ b/policyengine_us_data/utils/policyengine.py @@ -134,3 +134,13 @@ def has_policyengine_us_variables(*variables: str) -> bool: return False return set(variables).issubset(available_variables) + + +def supports_medicare_enrollment_input() -> bool: + return has_policyengine_us_variables("medicare_enrolled") + + +def supports_modeled_medicare_part_b_inputs() -> bool: + return has_policyengine_us_variables( + "medicare_part_b_premiums_reported", + ) diff --git a/tests/unit/test_cms_medicare_targets.py b/tests/unit/test_cms_medicare_targets.py new file mode 100644 index 000000000..d780bf8d4 --- /dev/null +++ b/tests/unit/test_cms_medicare_targets.py @@ -0,0 +1,25 @@ +import pytest + +from policyengine_us_data.utils.cms_medicare import ( + get_beneficiary_paid_medicare_part_b_premiums_notes, + get_beneficiary_paid_medicare_part_b_premiums_source, + get_beneficiary_paid_medicare_part_b_premiums_target, +) + + +def test_beneficiary_paid_medicare_part_b_target_2024_is_sourced(): + assert get_beneficiary_paid_medicare_part_b_premiums_target(2024) == pytest.approx( + 112e9 + ) + + +def test_beneficiary_paid_medicare_part_b_source_mentions_primary_sources(): + source = get_beneficiary_paid_medicare_part_b_premiums_source(2024) + assert "2025 Medicare Trustees Report" in source + assert "State Buy-In FAQ" in source + + +def test_beneficiary_paid_medicare_part_b_notes_describe_out_of_pocket_semantics(): + notes = get_beneficiary_paid_medicare_part_b_premiums_notes(2024) + assert "out-of-pocket" in notes + assert "gross trust-fund premium income" in notes diff --git a/tests/unit/test_medicare_part_b_inputs.py b/tests/unit/test_medicare_part_b_inputs.py new file mode 100644 index 000000000..c69e88789 --- /dev/null +++ b/tests/unit/test_medicare_part_b_inputs.py @@ -0,0 +1,27 @@ +from policyengine_us_data.datasets.cps.extended_cps import ( + CPS_ONLY_IMPUTED_VARIABLES, + supports_modeled_medicare_part_b_inputs, +) +from policyengine_us_data.datasets.puf.puf import MEDICAL_EXPENSE_CATEGORY_BREAKDOWNS +from policyengine_us_data.utils import policyengine as policyengine_utils + + +def test_medicare_part_b_clone_imputation_matches_installed_model_support(): + assert ("medicare_part_b_premiums" in set(CPS_ONLY_IMPUTED_VARIABLES)) is ( + not supports_modeled_medicare_part_b_inputs() + ) + + +def test_puf_medical_breakdown_still_sums_to_one(): + assert sum(MEDICAL_EXPENSE_CATEGORY_BREAKDOWNS.values()) == 1.0 + + +def test_supports_medicare_enrollment_input_allows_partial_support(monkeypatch): + monkeypatch.setattr( + policyengine_utils, + "has_policyengine_us_variables", + lambda *variables: variables == ("medicare_enrolled",), + ) + + assert policyengine_utils.supports_medicare_enrollment_input() is True + assert policyengine_utils.supports_modeled_medicare_part_b_inputs() is False