From 5a28895638493f6d320b5b8787819187f9fba62c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 21:08:51 -0400 Subject: [PATCH 1/5] Use modeled Medicare Part B inputs and targets --- changelog.d/712.fixed | 1 + policyengine_us_data/datasets/cps/cps.py | 3 +- .../datasets/cps/extended_cps.py | 1 - policyengine_us_data/datasets/puf/puf.py | 3 +- .../db/etl_national_targets.py | 17 ++++++-- policyengine_us_data/utils/cms_medicare.py | 41 +++++++++++++++++++ policyengine_us_data/utils/loss.py | 7 +++- tests/unit/test_cms_medicare_targets.py | 25 +++++++++++ tests/unit/test_medicare_part_b_inputs.py | 10 +++++ 9 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 changelog.d/712.fixed create mode 100644 policyengine_us_data/utils/cms_medicare.py create mode 100644 tests/unit/test_cms_medicare_targets.py create mode 100644 tests/unit/test_medicare_part_b_inputs.py diff --git a/changelog.d/712.fixed b/changelog.d/712.fixed new file mode 100644 index 00000000..5fea41f4 --- /dev/null +++ b/changelog.d/712.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 e227760a..ff248fe7 100644 --- a/policyengine_us_data/datasets/cps/cps.py +++ b/policyengine_us_data/datasets/cps/cps.py @@ -817,7 +817,8 @@ 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 + cps["medicare_part_b_premiums_reported"] = person.PEMCPREM + cps["medicare_enrolled"] = person.MCARE == 1 # 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 8e01f505..937269f0 100644 --- a/policyengine_us_data/datasets/cps/extended_cps.py +++ b/policyengine_us_data/datasets/cps/extended_cps.py @@ -150,7 +150,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", diff --git a/policyengine_us_data/datasets/puf/puf.py b/policyengine_us_data/datasets/puf/puf.py index bde0f33f..4d2ce72f 100644 --- a/policyengine_us_data/datasets/puf/puf.py +++ b/policyengine_us_data/datasets/puf/puf.py @@ -802,10 +802,11 @@ 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, } diff --git a/policyengine_us_data/db/etl_national_targets.py b/policyengine_us_data/db/etl_national_targets.py index b6aaa5c2..7747fe76 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 00000000..b710cbde --- /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 9a81ea9d..60042787 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_core.reforms import Reform from policyengine_us_data.utils.soi import pe_to_soi, get_soi @@ -25,7 +28,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/tests/unit/test_cms_medicare_targets.py b/tests/unit/test_cms_medicare_targets.py new file mode 100644 index 00000000..d780bf8d --- /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 00000000..a082720d --- /dev/null +++ b/tests/unit/test_medicare_part_b_inputs.py @@ -0,0 +1,10 @@ +from policyengine_us_data.datasets.cps.extended_cps import CPS_ONLY_IMPUTED_VARIABLES +from policyengine_us_data.datasets.puf.puf import MEDICAL_EXPENSE_CATEGORY_BREAKDOWNS + + +def test_medicare_part_b_not_qrf_imputed_for_clone_half(): + assert "medicare_part_b_premiums" not in set(CPS_ONLY_IMPUTED_VARIABLES) + + +def test_medicare_part_b_not_allocated_from_generic_puf_medical_expenses(): + assert "medicare_part_b_premiums" not in MEDICAL_EXPENSE_CATEGORY_BREAKDOWNS From 571ec42fbc4d3b69bb3f068e976db6119cd471b0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 21:10:20 -0400 Subject: [PATCH 2/5] Fix Medicare Part B changelog fragment --- changelog.d/{712.fixed => 713.fixed} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{712.fixed => 713.fixed} (100%) diff --git a/changelog.d/712.fixed b/changelog.d/713.fixed similarity index 100% rename from changelog.d/712.fixed rename to changelog.d/713.fixed From 03aeb6af4d13db3ce15e8b91a7b941a919154e3b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 21:58:35 -0400 Subject: [PATCH 3/5] Fix Medicare Part B data compatibility --- policyengine_us_data/datasets/cps/cps.py | 10 ++++++++-- policyengine_us_data/datasets/cps/extended_cps.py | 4 ++++ policyengine_us_data/datasets/puf/puf.py | 1 + policyengine_us_data/utils/policyengine.py | 7 +++++++ tests/unit/test_medicare_part_b_inputs.py | 15 ++++++++++----- 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/policyengine_us_data/datasets/cps/cps.py b/policyengine_us_data/datasets/cps/cps.py index e4e2496a..904c775d 100644 --- a/policyengine_us_data/datasets/cps/cps.py +++ b/policyengine_us_data/datasets/cps/cps.py @@ -37,6 +37,9 @@ assign_takeup_with_reported_anchors, reported_subsidized_marketplace_by_tax_unit, ) +from policyengine_us_data.utils.policyengine import ( + supports_modeled_medicare_part_b_inputs, +) CURRENT_HEALTH_COVERAGE_REPORTED_VAR_MAP = { @@ -820,8 +823,11 @@ 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_reported"] = person.PEMCPREM - cps["medicare_enrolled"] = person.MCARE == 1 + if supports_modeled_medicare_part_b_inputs(): + cps["medicare_part_b_premiums_reported"] = person.PEMCPREM + cps["medicare_enrolled"] = person.MCARE == 1 + 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 937269f0..e173ee90 100644 --- a/policyengine_us_data/datasets/cps/extended_cps.py +++ b/policyengine_us_data/datasets/cps/extended_cps.py @@ -19,6 +19,7 @@ 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, @@ -163,6 +164,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 4d2ce72f..2eba0091 100644 --- a/policyengine_us_data/datasets/puf/puf.py +++ b/policyengine_us_data/datasets/puf/puf.py @@ -808,6 +808,7 @@ class PUF_2024(PUF): "health_insurance_premiums_without_medicare_part_b": 0.453, "other_medical_expenses": 0.325, "over_the_counter_health_expenses": 0.085, + "medicare_part_b_premiums": 0.137, } if __name__ == "__main__": diff --git a/policyengine_us_data/utils/policyengine.py b/policyengine_us_data/utils/policyengine.py index 1d150ee9..64c44230 100644 --- a/policyengine_us_data/utils/policyengine.py +++ b/policyengine_us_data/utils/policyengine.py @@ -134,3 +134,10 @@ def has_policyengine_us_variables(*variables: str) -> bool: return False return set(variables).issubset(available_variables) + + +def supports_modeled_medicare_part_b_inputs() -> bool: + return has_policyengine_us_variables( + "medicare_part_b_premiums_reported", + "medicare_enrolled", + ) diff --git a/tests/unit/test_medicare_part_b_inputs.py b/tests/unit/test_medicare_part_b_inputs.py index a082720d..29a17f18 100644 --- a/tests/unit/test_medicare_part_b_inputs.py +++ b/tests/unit/test_medicare_part_b_inputs.py @@ -1,10 +1,15 @@ -from policyengine_us_data.datasets.cps.extended_cps import CPS_ONLY_IMPUTED_VARIABLES +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 -def test_medicare_part_b_not_qrf_imputed_for_clone_half(): - assert "medicare_part_b_premiums" not in set(CPS_ONLY_IMPUTED_VARIABLES) +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_medicare_part_b_not_allocated_from_generic_puf_medical_expenses(): - assert "medicare_part_b_premiums" not in MEDICAL_EXPENSE_CATEGORY_BREAKDOWNS +def test_puf_medical_breakdown_still_sums_to_one(): + assert sum(MEDICAL_EXPENSE_CATEGORY_BREAKDOWNS.values()) == 1.0 From 7972be96d7dfc27ea03fcaef539555ee16de03e0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 22:01:21 -0400 Subject: [PATCH 4/5] Format Part B data compatibility changes --- policyengine_us_data/datasets/cps/extended_cps.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/policyengine_us_data/datasets/cps/extended_cps.py b/policyengine_us_data/datasets/cps/extended_cps.py index e173ee90..e5208d63 100644 --- a/policyengine_us_data/datasets/cps/extended_cps.py +++ b/policyengine_us_data/datasets/cps/extended_cps.py @@ -19,7 +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.policyengine import ( + supports_modeled_medicare_part_b_inputs, +) from policyengine_us_data.utils.retirement_limits import ( get_retirement_limits, get_se_pension_limits, From fdd79f732824654e926e045102ab1be04c680550 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 23:20:41 -0400 Subject: [PATCH 5/5] Populate Medicare enrollment independently of reported premiums --- policyengine_us_data/datasets/cps/cps.py | 4 +++- policyengine_us_data/utils/policyengine.py | 5 ++++- tests/unit/test_medicare_part_b_inputs.py | 12 ++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/policyengine_us_data/datasets/cps/cps.py b/policyengine_us_data/datasets/cps/cps.py index 904c775d..1d05e2ca 100644 --- a/policyengine_us_data/datasets/cps/cps.py +++ b/policyengine_us_data/datasets/cps/cps.py @@ -38,6 +38,7 @@ reported_subsidized_marketplace_by_tax_unit, ) from policyengine_us_data.utils.policyengine import ( + supports_medicare_enrollment_input, supports_modeled_medicare_part_b_inputs, ) @@ -823,9 +824,10 @@ 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 + 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 - cps["medicare_enrolled"] = person.MCARE == 1 else: cps["medicare_part_b_premiums"] = person.PEMCPREM diff --git a/policyengine_us_data/utils/policyengine.py b/policyengine_us_data/utils/policyengine.py index 64c44230..869b95d9 100644 --- a/policyengine_us_data/utils/policyengine.py +++ b/policyengine_us_data/utils/policyengine.py @@ -136,8 +136,11 @@ def has_policyengine_us_variables(*variables: str) -> bool: 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", - "medicare_enrolled", ) diff --git a/tests/unit/test_medicare_part_b_inputs.py b/tests/unit/test_medicare_part_b_inputs.py index 29a17f18..c69e8878 100644 --- a/tests/unit/test_medicare_part_b_inputs.py +++ b/tests/unit/test_medicare_part_b_inputs.py @@ -3,6 +3,7 @@ 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(): @@ -13,3 +14,14 @@ def test_medicare_part_b_clone_imputation_matches_installed_model_support(): 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