Skip to content

Commit ac645ef

Browse files
authored
Impute selected_marketplace_plan_benchmark_ratio from CPS premiums (#801)
* Impute selected_marketplace_plan_benchmark_ratio from CPS premiums Previously `selected_marketplace_plan_benchmark_ratio` defaulted to 1.0 for every household, which means PolicyEngine-US treats every Marketplace enrollee as on the benchmark silver plan. In reality roughly 35% pick bronze and 15% pick gold/platinum, so downstream variables like `selected_marketplace_plan_premium_proxy` and the new `marketplace_net_premium` miss real variation. Adds `compute_marketplace_plan_benchmark_ratio` (pure-Python helper) and `add_marketplace_plan_benchmark_ratio` (CPS-stage integration) that back out the implied ratio per tax unit: reported_premium ≈ plan_cost − APTC plan_cost = SLCSP × ratio → ratio = (reported_premium + computed_PTC) / SLCSP Ratios are clipped to [0.5, 1.5] to handle CPS reporting noise and Marketplace-flag false positives. Non-takers and tax units with zero SLCSP keep the 1.0 default — those paths either zero out the plan proxy via `takes_up_aca_if_eligible` or have no benchmark to divide against. Pure-function helper lets us unit-test the math with synthetic inputs (no Microsimulation) covering the silver / bronze / gold / non-taker / zero-SLCSP / clipping cases. Closes #800. * Pass explicit period to Microsimulation calls for future-proofing
1 parent 1efa25a commit ac645ef

3 files changed

Lines changed: 219 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added a Marketplace plan benchmark ratio imputation that populates `selected_marketplace_plan_benchmark_ratio` per tax unit by backing out the implied plan cost from CPS-reported private health premiums and PolicyEngine-computed PTC.

policyengine_us_data/datasets/cps/cps.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ def generate(self):
181181
self.save_dataset(cps)
182182
logging.info("Adding takeup")
183183
add_takeup(self)
184+
logging.info("Imputing Marketplace plan benchmark ratio")
185+
add_marketplace_plan_benchmark_ratio(self)
184186
logging.info("Downsampling")
185187

186188
# Downsample
@@ -448,6 +450,109 @@ def add_takeup(self):
448450
self.save_dataset(data)
449451

450452

453+
def add_marketplace_plan_benchmark_ratio(self):
454+
"""Impute `selected_marketplace_plan_benchmark_ratio` per tax unit.
455+
456+
PolicyEngine-US's ACA PTC formulas assume the selected Marketplace plan
457+
costs exactly the Second Lowest Cost Silver Plan (SLCSP). That under- or
458+
over-states household out-of-pocket premium for the ~50% of Marketplace
459+
enrollees who select Bronze or Gold / Platinum plans instead.
460+
461+
For tax units that CPS flags as taking up Marketplace coverage, back out
462+
the implied plan-to-SLCSP ratio from:
463+
464+
reported_net_premium ≈ selected_plan_cost − APTC
465+
selected_plan_cost = SLCSP × benchmark_ratio
466+
→ benchmark_ratio = (reported_net_premium + computed_PTC) / SLCSP
467+
468+
The CPS private-health-premium field is net of APTC for subsidized
469+
enrollees, so adding back PolicyEngine's computed PTC recovers the
470+
sticker cost. Non-Marketplace tax units keep the default 1.0; the
471+
ratio is only consumed through `selected_marketplace_plan_premium_proxy`,
472+
which is zero when `takes_up_aca_if_eligible` is false, so the default
473+
value does not affect their downstream calculations.
474+
475+
Ratios are clipped to [0.5, 1.5] to handle CPS reporting noise and
476+
Marketplace-flag false positives. Outliers usually indicate the
477+
household reported a private or employer premium rather than a true
478+
Marketplace plan.
479+
"""
480+
data = self.load_dataset()
481+
482+
from policyengine_us import Microsimulation
483+
484+
baseline = Microsimulation(dataset=self)
485+
period = self.time_period
486+
487+
slcsp = baseline.calculate("slcsp", map_to="tax_unit", period=period).values
488+
aca_ptc = baseline.calculate("aca_ptc", map_to="tax_unit", period=period).values
489+
reported_premium = baseline.calculate(
490+
"health_insurance_premiums_without_medicare_part_b",
491+
map_to="tax_unit",
492+
period=period,
493+
).values
494+
takes_up_aca = (
495+
baseline.calculate(
496+
"takes_up_aca_if_eligible",
497+
map_to="tax_unit",
498+
period=period,
499+
)
500+
.values.astype(bool)
501+
)
502+
503+
data["selected_marketplace_plan_benchmark_ratio"] = (
504+
compute_marketplace_plan_benchmark_ratio(
505+
reported_premium=reported_premium,
506+
aca_ptc=aca_ptc,
507+
slcsp=slcsp,
508+
takes_up_aca=takes_up_aca,
509+
)
510+
)
511+
512+
self.save_dataset(data)
513+
514+
515+
MARKETPLACE_PLAN_BENCHMARK_RATIO_MIN = 0.5
516+
MARKETPLACE_PLAN_BENCHMARK_RATIO_MAX = 1.5
517+
518+
519+
def compute_marketplace_plan_benchmark_ratio(
520+
reported_premium: np.ndarray,
521+
aca_ptc: np.ndarray,
522+
slcsp: np.ndarray,
523+
takes_up_aca: np.ndarray,
524+
) -> np.ndarray:
525+
"""Back out the Marketplace plan-to-SLCSP ratio per tax unit.
526+
527+
Returns 1.0 for tax units that don't take up Marketplace coverage or that
528+
have no SLCSP (zero benchmark). Ratios for Marketplace takers are clipped
529+
to ``[MARKETPLACE_PLAN_BENCHMARK_RATIO_MIN, MARKETPLACE_PLAN_BENCHMARK_RATIO_MAX]``.
530+
531+
Args:
532+
reported_premium: CPS-reported annual private health insurance premium
533+
(net of APTC for subsidized Marketplace takers), at the tax-unit
534+
level in USD.
535+
aca_ptc: PolicyEngine-computed annual advance premium tax credit at
536+
the tax-unit level in USD.
537+
slcsp: Second Lowest Cost Silver Plan annual premium at the tax-unit
538+
level in USD.
539+
takes_up_aca: Boolean array at the tax-unit level, True when the
540+
household is modeled as taking up Marketplace coverage.
541+
542+
Returns:
543+
Array of benchmark ratios (unitless), same shape as the inputs.
544+
"""
545+
with np.errstate(divide="ignore", invalid="ignore"):
546+
raw = (reported_premium + aca_ptc) / np.where(slcsp > 0, slcsp, 1.0)
547+
clipped = np.clip(
548+
raw,
549+
MARKETPLACE_PLAN_BENCHMARK_RATIO_MIN,
550+
MARKETPLACE_PLAN_BENCHMARK_RATIO_MAX,
551+
)
552+
applicable = takes_up_aca.astype(bool) & (slcsp > 0)
553+
return np.where(applicable, clipped, 1.0)
554+
555+
451556
def uprate_cps_data(data, from_period, to_period):
452557
uprating = create_policyengine_uprating_factors_table()
453558
for variable in uprating.index.unique():
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Unit tests for the Marketplace plan benchmark ratio back-out."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
7+
from policyengine_us_data.datasets.cps.cps import (
8+
MARKETPLACE_PLAN_BENCHMARK_RATIO_MAX,
9+
MARKETPLACE_PLAN_BENCHMARK_RATIO_MIN,
10+
compute_marketplace_plan_benchmark_ratio,
11+
)
12+
13+
14+
def test_silver_plan_back_out_yields_unit_ratio() -> None:
15+
reported_premium = np.array([2_000.0])
16+
aca_ptc = np.array([4_000.0])
17+
slcsp = np.array([6_000.0])
18+
takes_up_aca = np.array([True])
19+
20+
result = compute_marketplace_plan_benchmark_ratio(
21+
reported_premium=reported_premium,
22+
aca_ptc=aca_ptc,
23+
slcsp=slcsp,
24+
takes_up_aca=takes_up_aca,
25+
)
26+
27+
np.testing.assert_allclose(result, [1.0])
28+
29+
30+
def test_bronze_plan_back_out_yields_sub_silver_ratio() -> None:
31+
reported_premium = np.array([800.0])
32+
aca_ptc = np.array([4_000.0])
33+
slcsp = np.array([6_000.0])
34+
takes_up_aca = np.array([True])
35+
36+
result = compute_marketplace_plan_benchmark_ratio(
37+
reported_premium=reported_premium,
38+
aca_ptc=aca_ptc,
39+
slcsp=slcsp,
40+
takes_up_aca=takes_up_aca,
41+
)
42+
43+
np.testing.assert_allclose(result, [0.8])
44+
45+
46+
def test_gold_plan_back_out_yields_above_silver_ratio() -> None:
47+
reported_premium = np.array([3_500.0])
48+
aca_ptc = np.array([4_000.0])
49+
slcsp = np.array([6_000.0])
50+
takes_up_aca = np.array([True])
51+
52+
result = compute_marketplace_plan_benchmark_ratio(
53+
reported_premium=reported_premium,
54+
aca_ptc=aca_ptc,
55+
slcsp=slcsp,
56+
takes_up_aca=takes_up_aca,
57+
)
58+
59+
np.testing.assert_allclose(result, [1.25])
60+
61+
62+
def test_non_marketplace_households_keep_default_ratio() -> None:
63+
reported_premium = np.array([500.0, 2_000.0])
64+
aca_ptc = np.array([0.0, 0.0])
65+
slcsp = np.array([5_000.0, 6_000.0])
66+
takes_up_aca = np.array([False, False])
67+
68+
result = compute_marketplace_plan_benchmark_ratio(
69+
reported_premium=reported_premium,
70+
aca_ptc=aca_ptc,
71+
slcsp=slcsp,
72+
takes_up_aca=takes_up_aca,
73+
)
74+
75+
np.testing.assert_allclose(result, [1.0, 1.0])
76+
77+
78+
def test_zero_slcsp_returns_default_ratio_even_for_marketplace_taker() -> None:
79+
reported_premium = np.array([1_000.0])
80+
aca_ptc = np.array([0.0])
81+
slcsp = np.array([0.0])
82+
takes_up_aca = np.array([True])
83+
84+
result = compute_marketplace_plan_benchmark_ratio(
85+
reported_premium=reported_premium,
86+
aca_ptc=aca_ptc,
87+
slcsp=slcsp,
88+
takes_up_aca=takes_up_aca,
89+
)
90+
91+
np.testing.assert_allclose(result, [1.0])
92+
93+
94+
def test_ratios_are_clipped_to_configured_window() -> None:
95+
reported_premium = np.array([0.0, 10_000.0])
96+
aca_ptc = np.array([0.0, 0.0])
97+
slcsp = np.array([6_000.0, 6_000.0])
98+
takes_up_aca = np.array([True, True])
99+
100+
result = compute_marketplace_plan_benchmark_ratio(
101+
reported_premium=reported_premium,
102+
aca_ptc=aca_ptc,
103+
slcsp=slcsp,
104+
takes_up_aca=takes_up_aca,
105+
)
106+
107+
np.testing.assert_allclose(
108+
result,
109+
[
110+
MARKETPLACE_PLAN_BENCHMARK_RATIO_MIN,
111+
MARKETPLACE_PLAN_BENCHMARK_RATIO_MAX,
112+
],
113+
)

0 commit comments

Comments
 (0)