From 5a28895638493f6d320b5b8787819187f9fba62c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 21:08:51 -0400 Subject: [PATCH 01/10] 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 000000000..5fea41f40 --- /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 e227760ae..ff248fe7c 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 8e01f5054..937269f07 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 bde0f33ff..4d2ce72fd 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 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 9a81ea9d6..600427871 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 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..a082720df --- /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 02/10] 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 03/10] 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 e4e2496a1..904c775d0 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 937269f07..e173ee90d 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 4d2ce72fd..2eba00911 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 1d150ee97..64c44230c 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 a082720df..29a17f182 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 04/10] 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 e173ee90d..e5208d630 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 d54c1914c1d56f84fa803b812dff349babca9abd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 22:35:22 -0400 Subject: [PATCH 05/10] Add Medicare Part B enrollment count target --- .../db/etl_national_targets.py | 10 +++ policyengine_us_data/utils/cms_medicare.py | 30 +++++++++ tests/unit/test_cms_medicare_targets.py | 19 ++++++ tests/unit/test_etl_national_targets.py | 63 +++++++++++++++++++ 4 files changed, 122 insertions(+) diff --git a/policyengine_us_data/db/etl_national_targets.py b/policyengine_us_data/db/etl_national_targets.py index 7747fe76b..c96767dc4 100644 --- a/policyengine_us_data/db/etl_national_targets.py +++ b/policyengine_us_data/db/etl_national_targets.py @@ -16,6 +16,9 @@ get_beneficiary_paid_medicare_part_b_premiums_notes, get_beneficiary_paid_medicare_part_b_premiums_source, get_beneficiary_paid_medicare_part_b_premiums_target, + get_medicare_part_b_enrollment_notes, + get_medicare_part_b_enrollment_source, + get_medicare_part_b_enrollment_target, ) from policyengine_us_data.utils.db import ( DEFAULT_YEAR, @@ -330,6 +333,13 @@ def extract_national_targets(year: int = DEFAULT_YEAR): "notes": "ACA Premium Tax Credit recipients", "year": HARDCODED_YEAR, }, + { + "constraint_variable": "medicare_enrolled", + "person_count": get_medicare_part_b_enrollment_target(HARDCODED_YEAR), + "source": get_medicare_part_b_enrollment_source(HARDCODED_YEAR), + "notes": get_medicare_part_b_enrollment_notes(HARDCODED_YEAR), + "year": HARDCODED_YEAR, + }, { "constraint_variable": "spm_unit_energy_subsidy_reported", "target_variable": "household_count", diff --git a/policyengine_us_data/utils/cms_medicare.py b/policyengine_us_data/utils/cms_medicare.py index b710cbdea..9dfd5b9d4 100644 --- a/policyengine_us_data/utils/cms_medicare.py +++ b/policyengine_us_data/utils/cms_medicare.py @@ -2,6 +2,10 @@ 2024: 139.837e9, } +MEDICARE_PART_B_ENROLLMENT_TARGETS = { + 2024: 62_084_000, +} + MEDICARE_STATE_BUY_IN_MINIMUM_BENEFICIARIES = { 2024: 10_000_000, @@ -13,6 +17,32 @@ } +def get_medicare_part_b_enrollment_target(year: int) -> float: + try: + return MEDICARE_PART_B_ENROLLMENT_TARGETS[year] + except KeyError as exc: + raise ValueError( + f"No Medicare Part B enrollment target sourced for {year}." + ) from exc + + +def get_medicare_part_b_enrollment_source(year: int) -> str: + enrollment = MEDICARE_PART_B_ENROLLMENT_TARGETS[year] + return ( + "CMS 2024 Medicare Trustees Report Table V.B3 intermediate estimate " + f"for {year} Part B enrollment ({enrollment:,.0f} beneficiaries)" + ) + + +def get_medicare_part_b_enrollment_notes(year: int) -> str: + return ( + "Total Medicare Part B enrollment count. This is intentionally a " + "separate calibration anchor from beneficiary-paid Part B premiums, " + "because some enrollees have premiums paid on their behalf through " + "MSP/state buy-in pathways." + ) + + def get_beneficiary_paid_medicare_part_b_premiums_target(year: int) -> float: try: return BENEFICIARY_PAID_MEDICARE_PART_B_PREMIUM_TARGETS[year] diff --git a/tests/unit/test_cms_medicare_targets.py b/tests/unit/test_cms_medicare_targets.py index d780bf8d4..5b4f6fe68 100644 --- a/tests/unit/test_cms_medicare_targets.py +++ b/tests/unit/test_cms_medicare_targets.py @@ -4,6 +4,9 @@ get_beneficiary_paid_medicare_part_b_premiums_notes, get_beneficiary_paid_medicare_part_b_premiums_source, get_beneficiary_paid_medicare_part_b_premiums_target, + get_medicare_part_b_enrollment_notes, + get_medicare_part_b_enrollment_source, + get_medicare_part_b_enrollment_target, ) @@ -23,3 +26,19 @@ 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 + + +def test_medicare_part_b_enrollment_target_2024_is_sourced(): + assert get_medicare_part_b_enrollment_target(2024) == pytest.approx(62_084_000) + + +def test_medicare_part_b_enrollment_source_mentions_trustees_table(): + source = get_medicare_part_b_enrollment_source(2024) + assert "2024 Medicare Trustees Report" in source + assert "Table V.B3" in source + + +def test_medicare_part_b_enrollment_notes_describe_complementary_semantics(): + notes = get_medicare_part_b_enrollment_notes(2024) + assert "separate calibration anchor" in notes + assert "MSP/state buy-in" in notes diff --git a/tests/unit/test_etl_national_targets.py b/tests/unit/test_etl_national_targets.py index 84d8c748b..e2cf04e2d 100644 --- a/tests/unit/test_etl_national_targets.py +++ b/tests/unit/test_etl_national_targets.py @@ -199,3 +199,66 @@ def test_load_national_targets_supports_liheap_household_counts(tmp_path, monkey ).first() assert liheap_target is not None assert liheap_target.value == 5_876_646 + + +def test_load_national_targets_supports_medicare_part_b_enrollment_counts( + tmp_path, monkeypatch +): + calibration_dir = tmp_path / "calibration" + calibration_dir.mkdir() + db_uri = f"sqlite:///{calibration_dir / 'policy_data.db'}" + engine = create_database(db_uri) + + with Session(engine) as session: + national = _make_stratum(session, notes="United States") + assert national is not None + + monkeypatch.setattr( + "policyengine_us_data.db.etl_national_targets.STORAGE_FOLDER", + tmp_path, + ) + + conditional_targets = [ + { + "constraint_variable": "medicare_enrolled", + "person_count": 62_084_000, + "source": "CMS 2024 Medicare Trustees Report Table V.B3", + "notes": "Total Medicare Part B enrollment count", + "year": 2024, + } + ] + + load_national_targets( + direct_targets_df=pd.DataFrame(), + tax_filer_df=pd.DataFrame(), + tax_expenditure_df=pd.DataFrame(), + conditional_targets=conditional_targets, + ) + + with Session(engine) as session: + medicare_stratum = session.exec( + select(Stratum).where( + Stratum.notes == "National medicare_enrolled Recipients" + ) + ).first() + assert medicare_stratum is not None + + constraints = { + ( + constraint.constraint_variable, + constraint.operation, + constraint.value, + ) + for constraint in medicare_stratum.constraints_rel + } + assert ("medicare_enrolled", ">", "0") in constraints + + medicare_target = session.exec( + select(Target).where( + Target.stratum_id == medicare_stratum.stratum_id, + Target.variable == "person_count", + Target.period == 2024, + ) + ).first() + assert medicare_target is not None + assert medicare_target.value == 62_084_000 From 26969ed6ae711b4698958d25afe91ddb3c252ee6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 22:36:02 -0400 Subject: [PATCH 06/10] Add changelog for Medicare enrollment target --- changelog.d/716.fixed | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/716.fixed diff --git a/changelog.d/716.fixed b/changelog.d/716.fixed new file mode 100644 index 000000000..2627d3773 --- /dev/null +++ b/changelog.d/716.fixed @@ -0,0 +1 @@ +Adds a CMS-sourced Medicare Part B enrollment count target to complement the beneficiary-paid premium calibration target. From 49b74e83d8634b269f757b2bf3fa1877cae11c51 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 22:38:01 -0400 Subject: [PATCH 07/10] Use Medicare Part B enrollment target in loss matrix --- policyengine_us_data/utils/loss.py | 16 ++++++++ tests/unit/calibration/test_loss_targets.py | 41 +++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/policyengine_us_data/utils/loss.py b/policyengine_us_data/utils/loss.py index dceddb142..a4f6b465d 100644 --- a/policyengine_us_data/utils/loss.py +++ b/policyengine_us_data/utils/loss.py @@ -14,6 +14,7 @@ ) from policyengine_us_data.utils.cms_medicare import ( get_beneficiary_paid_medicare_part_b_premiums_target, + get_medicare_part_b_enrollment_target, ) from policyengine_us_data.db.etl_irs_soi import get_national_geography_soi_target from policyengine_core.reforms import Reform @@ -110,6 +111,15 @@ ], } + +def _add_medicare_part_b_enrollment_target(loss_matrix, targets_array, sim): + label = "nation/cms/medicare_part_b_enrollment" + enrolled = sim.calculate("medicare_enrolled", map_to="person", period=2025).values + loss_matrix[label] = sim.map_result(enrolled.astype(float), "person", "household") + targets_array.append(get_medicare_part_b_enrollment_target(2024)) + return targets_array, loss_matrix + + ACA_SPENDING_TARGETS = { 2024: 98e9, } @@ -574,6 +584,12 @@ def build_loss_matrix(dataset: type, time_period): targets_array.append(aca_enrollment_target) + targets_array, loss_matrix = _add_medicare_part_b_enrollment_target( + loss_matrix, + targets_array, + sim, + ) + # Treasury EITC loss_matrix["nation/treasury/eitc"] = sim.calculate( diff --git a/tests/unit/calibration/test_loss_targets.py b/tests/unit/calibration/test_loss_targets.py index b15640504..178626e92 100644 --- a/tests/unit/calibration/test_loss_targets.py +++ b/tests/unit/calibration/test_loss_targets.py @@ -3,6 +3,7 @@ import pytest from policyengine_us_data.utils.loss import ( + _add_medicare_part_b_enrollment_target, _get_aca_national_targets, _add_ctc_targets, _get_medicaid_national_targets, @@ -89,6 +90,26 @@ def map_result(self, values, source_entity, target_entity, how=None): return np.asarray(values, dtype=np.float32) +class _FakeMedicareEnrollmentSimulation: + def __init__(self): + self.calculate_calls = [] + self.map_result_calls = [] + + def calculate(self, variable, map_to=None, period=None): + self.calculate_calls.append((variable, map_to, period)) + if variable != "medicare_enrolled": + raise AssertionError(f"Unexpected variable {variable!r}") + if map_to != "person": + raise AssertionError(f"Unexpected map_to {map_to!r}") + return _FakeArrayResult([1.0, 0.0, 1.0]) + + def map_result(self, values, source_entity, target_entity, how=None): + self.map_result_calls.append((source_entity, target_entity, how)) + assert source_entity == "person" + assert target_entity == "household" + return np.asarray(values, dtype=np.float32) + + def test_add_ctc_targets(monkeypatch): monkeypatch.setattr( "policyengine_us_data.utils.loss.get_national_geography_soi_target", @@ -123,3 +144,23 @@ def test_add_ctc_targets(monkeypatch): loss_matrix["nation/irs/non_refundable_ctc_count"], np.array([1.0, 1.0, 0.0], dtype=np.float32), ) + + +def test_add_medicare_part_b_enrollment_target(monkeypatch): + monkeypatch.setattr( + "policyengine_us_data.utils.loss.get_medicare_part_b_enrollment_target", + lambda year: 62_084_000.0, + ) + sim = _FakeMedicareEnrollmentSimulation() + + targets, loss_matrix = _add_medicare_part_b_enrollment_target( + pd.DataFrame(), + [], + sim, + ) + + assert targets == [62_084_000.0] + np.testing.assert_array_equal( + loss_matrix["nation/cms/medicare_part_b_enrollment"], + np.array([1.0, 0.0, 1.0], dtype=np.float32), + ) From 1f7bbb6a238a9cc7931cf83b25dc3a43651e1c9a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 22:38:43 -0400 Subject: [PATCH 08/10] Include Medicare enrollment target in training config --- policyengine_us_data/calibration/target_config.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/policyengine_us_data/calibration/target_config.yaml b/policyengine_us_data/calibration/target_config.yaml index 2ec74b1e2..b0b7c7091 100644 --- a/policyengine_us_data/calibration/target_config.yaml +++ b/policyengine_us_data/calibration/target_config.yaml @@ -93,6 +93,9 @@ include: geo_level: national - variable: medicaid geo_level: national + - variable: person_count + geo_level: national + domain_variable: medicare_enrolled - variable: medicare_part_b_premiums geo_level: national - variable: other_medical_expenses From fdd79f732824654e926e045102ab1be04c680550 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Apr 2026 23:20:41 -0400 Subject: [PATCH 09/10] 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 904c775d0..1d05e2ca6 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 64c44230c..869b95d9a 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 29a17f182..c69e88789 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 From 091f6f89922984e1ad1c664d33b1e05fb390d63c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 10 Apr 2026 09:35:38 -0400 Subject: [PATCH 10/10] Trigger PR checks