Skip to content

Replace per-state CMS APTC target with IRS SOI Net Premium Tax Credit (A11560/N11560) #805

@MaxGhenis

Description

@MaxGhenis

Revised design (after Codex review)

The original proposal was wrong in its column-code choice and claimed effect size. Correcting:

Correct IRS SOI columns

IRS Historical Table 2 publishes three related PTC series — picking the wrong one was the original error:

Column Meaning National TY2022
A85770 / N85770 Total premium tax credit (claimed, pre-reconciliation-payback) ~$53.9B
A85775 / N85775 Advance premium tax credit (APTC paid) ~$60.7B
A11560 / N11560 Net premium tax credit (claimed after Form 8962 reconciliation) ~$2.4B

The model's aca_ptc variable at policyengine_us/variables/gov/aca/ptc/aca_ptc.py computes max(0, benchmark - income * applicable_figure) with no Form 8962 reconciliation modeled. That is the gross PTC entitlement — closest to A85770 total PTC. A11560 is the wrong target; it's the residual after reconciliation pay-back, which doesn't exist in the microsim.

Use A85770/N85770 (total premium tax credit), not A11560/N11560 (net).

NY Essential Plan / BHP expectations corrected

BHP federal funding flows to the state as a separate §1331 payment, not through APTC/PTC on Form 8962 — so BHP enrollees do show with PTC = 0 on tax returns. TY2022 NY: A85770 ≈ $0.49B vs non-BHP states' scaled-by-population peers at 2–3× higher per capita. Directionally the BHP signal is visible in A85770.

But the "591% → ~5%" claim was specific to misusing A11560 as a state-share proxy against an $98B national anchor — a compensating-error hack, not a real definition match. Realistic expectation for this fix (using A85770):

  • NY per-state ACA calibration error: 591% → roughly 100–200% (still the biggest outlier because the microsim doesn't model Essential Plan, just now measured against a proper PTC-claimed target rather than CMS APTC outlay).
  • Other states with 40%+ errors on main: the p90 state error should drop from today's ~26% to ~43% with the correct target swap if nothing else changes. That's worse on median p90 but correct on definition.

The NY problem isn't really solvable on the target side — it needs BHP modeling upstream in policyengine-us (separate issue). The value of this issue's fix is defining the right quantity to calibrate, not reducing the NY-specific error below 100%.

National consistency (what PR #803 taught us)

Keeping the current national $98B CMS APTC anchor alongside new A85770-based state targets recreates the EITC "total of state rows ≠ national row" contradiction that PR #803 removed. Three ways to resolve:

  1. Drop the CMS national anchor, use SOI A85770 national row ($53.9B, uprated to target year) as the aggregate, and A85770 state rows as the distribution. Same pattern as EITC post-Add state and AGI cross-tab EITC calibration targets (#802) #803. Recommended.
  2. Keep CMS APTC national as the anchor, use CMS state shares (current behavior), accept that APTC ≠ PTC and the state tolerance must be 50%+.
  3. Keep CMS APTC national + add SOI state rows rescaled to sum to the national anchor. This is the compensating-error hack — forces the state distribution to match SOI shape but a CMS level. Rejected.

Going with (1). The CMS national number is still useful as a sanity crosscheck, but not as a calibration target.

Tolerance

Tightening test_aca_calibration from 500% to 25% is not defensible. Per Codex diagnostics: even with correct A85770 state shares, current model gives ~25% median / ~43% p90 state error, and NY remains a 100%+ outlier while BHP isn't modeled upstream. Propose 50% tolerance, which allows the p90 to pass while still flagging egregious drift.

Revised CSV column naming

policyengine_us_data/storage/calibration_targets/aca_ptc_state.csv

GEO_ID,Returns,TotalPTCAmount
0400000US01,N85770_value,A85770_value_dollars
...

(renamed from original Amount to make the SOI column explicit)

Unrelated bug this surfaced

policyengine_us_data/db/etl_irs_soi.py:95 maps aca_ptc to IRS code 85530. IRS Historical Table 2 documentation labels 85530 as Additional Medicare tax, not PTC. Likely a transcription error — the correct code is 85770 (total PTC) or 85775 (advance PTC) or 11560 (net PTC), depending on intent. Will file as a separate issue.

Revised sequencing

  1. Fix the etl_irs_soi.py column-code bug (tiny, independent).
  2. Add state-specific A85770/N85770 extraction to the SOI Historical Table 2 refresh (reuses Add state and AGI cross-tab EITC calibration targets (#802) #803's infrastructure).
  3. Swap state ACA targets from CMS-APTC rescaled to SOI A85770 uprated; drop the $98B national anchor in favor of SOI national A85770.
  4. Set test tolerance to 50%, expect NY to remain the outlier.
  5. Model BHP upstream in policyengine-us (separate issue) to close the NY gap.

Original proposal (superseded)

The rest of this issue kept for history, but the column-code, impact, and tolerance claims above supersede the originals.


Context

PR #803 (issue #802) rewrote EITC calibration on coherent IRS SOI TY2022 anchors after noticing that the loss function was targeting Treasury's EITC outlay parameter (~$67B) as if it were directly comparable to the eitc microsim variable (which computes total claimed EITC, ~$59B per SOI). The same class of bug exists for ACA Premium Tax Credit state-level calibration, and it's causing the test_aca_calibration / test_sparse_aca_calibration integration tests to fail on every PR that touches calibration — most dramatically in New York, where simulated aca_ptc is ~$6bn vs a target of ~$0.86bn (591% error, above the 500% tolerance).

This issue proposes replacing the per-state ACA spending target with an IRS SOI Net Premium Tax Credit target drawn from Historical Table 2, the same workbook PR #803 already extracts for EITC.

The definitional mismatch today

Current wiring in policyengine_us_data/utils/loss.py (~L960–988) builds per-state targets as:

spending_by_state["spending"] = spending_by_state["spending"] * 12
spending_by_state["spending"] = spending_by_state["spending"] * (
    aca_spending_target / spending_by_state["spending"].sum()
)

label = f"state/irs/aca_spending/{row['state'].lower()}"
loss_matrix[label] = aca_value * in_state  # aca_value = sim.calculate("aca_ptc")
targets_array.append(annual_target)

Three problems stack:

  1. Outlay vs claimed. aca_spending_and_enrollment_2024.csv holds CMS's Advance Premium Tax Credit (APTC) — the monthly payment CMS makes to insurers — multiplied by 12 to annualize. The microsim variable aca_ptc is the Premium Tax Credit claimed on the tax return, after the reconciliation on Form 8962. These are similar in aggregate but diverge household by household (income changes mid-year, takeup changes, filing-status changes, reconciliation payback) and can diverge badly state by state.

  2. NY Essential Plan / MN MinnesotaCare. NY and MN operate federally-funded Basic Health Programs (BHP) that cover the 138–200% FPL population who would otherwise take APTC on the Marketplace. Approximately 1.5M New Yorkers enrolled in the Essential Plan in 2024, meaning NY's Marketplace APTC (the current target) is artificially small while NY's microsim aca_ptc reflects full APTC-eligible simulation because policyengine-us doesn't model BHP as an APTC-disqualifier. The $0.86bn NY target vs $6bn simulation is mostly this — not a calibration bug the optimizer can fix.

  3. Mislabeled source. The loss-matrix label is state/irs/aca_spending/{state}, but the underlying data is published by CMS, not IRS. The label is a red herring when debugging.

Proposal

Replace per-state CMS APTC with per-state IRS SOI Net Premium Tax Credit (claimed on tax returns).

Target source

IRS SOI Historical Table 2, published annually as {YY}in55cmcsv.csv — the same workbook PR #803 already pulls for state EITC. It contains two PTC columns:

  • N11560 — Number of returns with net premium tax credit
  • A11560 — Amount of net premium tax credit (in thousands, after reconciliation)

Coverage: all 50 states + DC + the US aggregate row, annually from TY2014 forward. TY2022 is current (TY2023 published ~Q4 2025, TY2024 published ~Q4 2026). IRS SOI: https://www.irs.gov/statistics/soi-tax-stats-historic-table-2

New CSV

policyengine_us_data/storage/calibration_targets/aca_ptc_state.csv:

GEO_ID,Returns,Amount
0400000US01,...,...
0400000US02,...,...
...

Format matches eitc_state.csv (added in PR #803). 51 rows × 2 metrics = ~102 new loss-matrix columns. Extract via a parameterized refresh script following the pattern in refresh_eitc_state_and_agi_targets.py — most of that machinery can be factored into a shared _extract_soi_historical_table_2_column(year, column_code) helper.

New helper in loss.py

Mirror _add_state_eitc_targets from PR #803:

def _add_state_aca_ptc_targets(
    loss_matrix, targets_list, sim,
    aca_ptc_spending_uprating, population_uprating,
):
    aca_ptc_path = CALIBRATION_FOLDER / "aca_ptc_state.csv"
    if not aca_ptc_path.exists():
        return targets_list, loss_matrix

    df = pd.read_csv(aca_ptc_path, comment="#")
    aca_ptc = sim.calculate("aca_ptc").values  # tax unit
    aca_ptc_returns_tu = (aca_ptc > 0).astype(float)
    aca_ptc_hh = sim.map_result(aca_ptc, "tax_unit", "household")
    aca_ptc_returns_hh = sim.map_result(aca_ptc_returns_tu, "tax_unit", "household")
    state_fips = _compute_state_fips(sim)

    for _, row in df.iterrows():
        fips = str(row["GEO_ID"])[-2:]
        in_state = (state_fips == fips).to_numpy()

        returns_label = f"nation/irs/aca_ptc/returns/state_{fips}"
        loss_matrix[returns_label] = np.where(in_state, aca_ptc_returns_hh, 0.0)
        if not _skip_unverified_target(row["Returns"]):
            targets_list.append(float(row["Returns"]) * population_uprating)
        else:
            del loss_matrix[returns_label]

        amount_label = f"nation/irs/aca_ptc/amount/state_{fips}"
        loss_matrix[amount_label] = np.where(in_state, aca_ptc_hh, 0.0)
        if not _skip_unverified_target(row["Amount"]):
            targets_list.append(float(row["Amount"]) * aca_ptc_spending_uprating)
        else:
            del loss_matrix[amount_label]

    return targets_list, loss_matrix

Call site: right after _add_state_eitc_targets in the EITC block (L758 area), with aca_ptc_spending_uprating derived from a CBO/JCT PTC trajectory parameter. Falls back to the EITC spending uprating if no PTC-specific trajectory is available — both are refundable credits that grow with wages and enrollment.

Removals

Remove the existing per-state CMS APTC loop at loss.py L960–988 (state/irs/aca_spending/{state}) and the state-level enrollment loop at L1005–1015 if the reviewer agrees that SOI's returns count gives equivalent (or better) signal. The national CMS APTC aggregate at L711–715 (nation/gov/aca_spending) stays — it's a legitimate budget-outlay anchor and is not per-state, so the NY Essential Plan / BHP issue doesn't cascade from it.

Delete the legacy aca_spending_and_enrollment_2024.csv per-state rows if they're only used for this purpose; keep the CSV if _get_aca_national_targets still needs the national-aggregate scaling.

Why this is the right fix (vs. modeling BHP upstream)

Modeling NY Essential Plan + MN MinnesotaCare in policyengine-us is the correct long-term fix (see separate issue for that), but it doesn't fix the per-state ACA calibration by itself because:

  1. Every state's APTC target still mixes advance-payment timing with end-of-year reconciliation noise.
  2. Other states that don't operate BHPs also show wide APTC vs PTC gaps (IN 52%, UT 43%, ID 64%, WY 141% on main). Those aren't BHP — they're genuine advance-vs-claimed divergence.
  3. IRS SOI is the same primary-source data the tax model is tuned against for every other refundable credit target in this file; using it for PTC is internally consistent with the EITC/CTC calibration approach.

BHP modeling is still worth doing, but it's a separate upstream change. This issue is the complete target-side fix.

Expected impact

  • NY per-state ACA calibration error: 591% → ~5% (because SOI A11560 for NY reflects the low PTC claim volume that results from the Essential Plan diverting enrollees, matching what the microsim produces when BHP-eligible NYers don't claim PTC — which happens approximately because their imputed AGI + benchmark premium + takeup rarely generates large PTC in the CPS-derived EnhancedCPS anyway).
  • Other 9+ states with 40%+ APTC-vs-PTC errors on main: most should drop under 20% because SOI's reconciled PTC is the quantity the microsim actually computes.
  • test_aca_calibration and test_sparse_aca_calibration with their 500% tolerance should pass cleanly.
  • JCT tax-expenditure targets (_add_tax_expenditure_targets, loss.py:1101) are unaffected — they use a separate simulate-repeal methodology that already works correctly.

Test additions

tests/unit/calibration/test_aca_ptc_state_targets.py:

  • test_aca_ptc_state_csv_present_with_expected_columns
  • test_aca_ptc_state_totals_match_irs_national_aggregate (assert within 1% of the A11560 US row, same pattern as the EITC test)
  • test_add_state_aca_ptc_targets_produces_aligned_columns_and_targets
  • test_placeholder_rows_are_skipped_without_breaking_alignment
  • test_legacy_state_aca_spending_targets_removed (regression: assert no state/irs/aca_spending/ columns in built loss matrix)

tests/integration/test_enhanced_cps.py: tighten test_aca_calibration's 500% tolerance to 25% and expect it to still pass.

Related

Suggested sequencing

  1. Diagnostic first: a script that computes per-state aca_ptc vs both CMS APTC and SOI A11560 for TY2022 off the current enhanced_cps_2024.h5, to quantify how much of the per-state error is pure definition vs pure calibration.
  2. If SOI matches simulation more closely (expected), land this issue.
  3. File and land the BHP upstream issue separately.

Filed after noticing the ACA test failures on PR #803 and the pre-existing 880% NY error on PR #794 — same structural problem across both.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions