Merge pull request 'feat: skin sub budget neutrality notebook' (#291) from feat/skin-sub-budget-neutrality into main
Some checks failed
CI / skinny-install (aco) (push) Successful in 49s
CI / skinny-install (bls) (push) Successful in 26s
CI / skinny-install (ccw) (push) Successful in 28s
CI / skinny-install (cli) (push) Successful in 33s
CI / skinny-install (cms) (push) Successful in 29s
CI / skinny-install (conf) (push) Successful in 22s
CI / skinny-install (opps) (push) Successful in 35s
CI / skinny-install (perf) (push) Successful in 30s
CI / skinny-install (pfs) (push) Successful in 37s
CI / skinny-install (rex) (push) Successful in 27s
Infra CI / notebooks (push) Successful in 7s
Infra CI / zotero (push) Successful in 6s
Infra CI / docs (push) Failing after 6s
Infra CI / api (push) Successful in 7s
CI / lint-test (push) Successful in 1m15s
CI / skinny-install (api) (push) Successful in 27s
CI / skinny-install (bcda) (push) Successful in 32s
CI / skinny-install (bib) (push) Successful in 32s
Infra CI / mc (push) Successful in 8s
Deploy / build-scan-report (push) Failing after 1m31s
Some checks failed
CI / skinny-install (aco) (push) Successful in 49s
CI / skinny-install (bls) (push) Successful in 26s
CI / skinny-install (ccw) (push) Successful in 28s
CI / skinny-install (cli) (push) Successful in 33s
CI / skinny-install (cms) (push) Successful in 29s
CI / skinny-install (conf) (push) Successful in 22s
CI / skinny-install (opps) (push) Successful in 35s
CI / skinny-install (perf) (push) Successful in 30s
CI / skinny-install (pfs) (push) Successful in 37s
CI / skinny-install (rex) (push) Successful in 27s
Infra CI / notebooks (push) Successful in 7s
Infra CI / zotero (push) Successful in 6s
Infra CI / docs (push) Failing after 6s
Infra CI / api (push) Successful in 7s
CI / lint-test (push) Successful in 1m15s
CI / skinny-install (api) (push) Successful in 27s
CI / skinny-install (bcda) (push) Successful in 32s
CI / skinny-install (bib) (push) Successful in 32s
Infra CI / mc (push) Successful in 8s
Deploy / build-scan-report (push) Failing after 1m31s
This commit was merged in pull request #291.
This commit is contained in:
564
notebooks/skin_sub_budget_neutrality.py
Normal file
564
notebooks/skin_sub_budget_neutrality.py
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
import marimo
|
||||||
|
|
||||||
|
__generated_with = "0.21.1"
|
||||||
|
app = marimo.App(width="medium")
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _():
|
||||||
|
import marimo as mo
|
||||||
|
|
||||||
|
return (mo,)
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
# PFS Budget Neutrality Impact of Skin Substitute Codes
|
||||||
|
|
||||||
|
The Physician Fee Schedule is **budget neutral** — when CMS raises
|
||||||
|
RVUs for any service, it must lower them elsewhere (or reduce the
|
||||||
|
conversion factor) so aggregate spending stays flat. This notebook
|
||||||
|
quantifies how skin substitute application code revaluations
|
||||||
|
redistribute payment away from other services.
|
||||||
|
|
||||||
|
**Mechanism:** CMS publishes ~11,000 HCPCS codes with Work, PE, and
|
||||||
|
MP RVUs. When the sum of (RVU × frequency) grows, the conversion
|
||||||
|
factor or a budget neutrality adjustor (BNA) scales down to
|
||||||
|
compensate. Every code shares the compression proportionally.
|
||||||
|
|
||||||
|
**What this means:** A PE RVU increase on 15271–15278 is not free
|
||||||
|
money — it is a tax on every other service in the fee schedule.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _():
|
||||||
|
import altair as alt
|
||||||
|
import polars as pl
|
||||||
|
from conf import connect
|
||||||
|
from pfs.rules import RULES
|
||||||
|
|
||||||
|
con = connect.duckdb()
|
||||||
|
|
||||||
|
def q(sql):
|
||||||
|
return con.execute(sql).pl()
|
||||||
|
|
||||||
|
SKIN_CODES = "('15271','15272','15273','15274','15275','15276','15277','15278')"
|
||||||
|
|
||||||
|
return RULES, SKIN_CODES, alt, con, pl, q
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. Skin sub RVU growth vs. total pool ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 1. Skin Substitute Share of the RVU Pool
|
||||||
|
|
||||||
|
The unweighted RVU pool (sum of all base-mod RVUs across ~11k codes)
|
||||||
|
grows over time as CMS adds codes and revalues services. The skin
|
||||||
|
sub application codes (15271–15278) are a small but growing share.
|
||||||
|
|
||||||
|
> **Note:** Budget neutrality operates on *frequency-weighted* RVUs
|
||||||
|
> (RVU × utilization), not unweighted sums. Without CMS utilization
|
||||||
|
> data, this analysis uses unweighted RVUs as a structural proxy.
|
||||||
|
> The actual impact is amplified by the explosive volume growth in
|
||||||
|
> skin substitute claims documented by OIG.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(SKIN_CODES, alt, mo, q):
|
||||||
|
pool_share = q(f"""
|
||||||
|
WITH pool AS (
|
||||||
|
SELECT year,
|
||||||
|
sum(work_rvu + non_fac_pe_rvu + mp_rvu) as total_rvu,
|
||||||
|
sum(CASE WHEN hcpcs IN {SKIN_CODES}
|
||||||
|
THEN work_rvu + non_fac_pe_rvu + mp_rvu ELSE 0 END) as skin_rvu,
|
||||||
|
sum(CASE WHEN hcpcs NOT IN {SKIN_CODES}
|
||||||
|
THEN work_rvu + non_fac_pe_rvu + mp_rvu ELSE 0 END) as other_rvu
|
||||||
|
FROM pfs.rvu
|
||||||
|
WHERE mod IS NULL OR mod = ''
|
||||||
|
GROUP BY year
|
||||||
|
)
|
||||||
|
SELECT year,
|
||||||
|
round(skin_rvu, 2) as skin_rvu,
|
||||||
|
round(total_rvu, 1) as total_rvu,
|
||||||
|
round(100.0 * skin_rvu / total_rvu, 4) as skin_pct,
|
||||||
|
round(skin_rvu - LAG(skin_rvu) OVER (ORDER BY year), 2) as skin_delta,
|
||||||
|
round(total_rvu - LAG(total_rvu) OVER (ORDER BY year), 1) as pool_delta
|
||||||
|
FROM pool ORDER BY year
|
||||||
|
""")
|
||||||
|
|
||||||
|
share_chart = (
|
||||||
|
alt.Chart(pool_share.to_pandas())
|
||||||
|
.mark_bar()
|
||||||
|
.encode(
|
||||||
|
x=alt.X("year:O", title="Year"),
|
||||||
|
y=alt.Y("skin_rvu:Q", title="Skin Sub Total NF RVUs (8 codes)"),
|
||||||
|
tooltip=["year", "skin_rvu", "total_rvu", "skin_pct", "skin_delta"],
|
||||||
|
)
|
||||||
|
.properties(title="Skin Sub Application Codes — Total NF RVUs by Year", width=700, height=300)
|
||||||
|
)
|
||||||
|
|
||||||
|
mo.vstack([share_chart, pool_share])
|
||||||
|
return (pool_share,)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. PE RVU revaluation trajectory ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 2. Practice Expense RVU Revaluation
|
||||||
|
|
||||||
|
PE is the largest RVU component for skin sub application codes and
|
||||||
|
is where the budget neutrality tax bites hardest. When CMS increases
|
||||||
|
PE RVUs for these codes (e.g., to reflect updated clinical labor
|
||||||
|
rates or supply costs), **all other codes' PE RVUs must absorb a
|
||||||
|
compensating reduction** via the BNA.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(alt, q):
|
||||||
|
pe_trajectory = q("""
|
||||||
|
SELECT year, hcpcs,
|
||||||
|
CASE hcpcs
|
||||||
|
WHEN '15271' THEN '15271 trunk <100cm²'
|
||||||
|
WHEN '15272' THEN '15272 trunk add-on'
|
||||||
|
WHEN '15275' THEN '15275 face <100cm²'
|
||||||
|
WHEN '15276' THEN '15276 face add-on'
|
||||||
|
END as label,
|
||||||
|
non_fac_pe_rvu,
|
||||||
|
work_rvu,
|
||||||
|
mp_rvu,
|
||||||
|
non_fac_pe_rvu + work_rvu + mp_rvu as total_nf_rvu
|
||||||
|
FROM pfs.rvu
|
||||||
|
WHERE hcpcs IN ('15271','15272','15275','15276')
|
||||||
|
AND (mod IS NULL OR mod = '')
|
||||||
|
ORDER BY year, hcpcs
|
||||||
|
""")
|
||||||
|
|
||||||
|
pe_chart = (
|
||||||
|
alt.Chart(pe_trajectory.to_pandas())
|
||||||
|
.mark_line(point=True)
|
||||||
|
.encode(
|
||||||
|
x=alt.X("year:O", title="Year"),
|
||||||
|
y=alt.Y("non_fac_pe_rvu:Q", title="Non-Facility PE RVU"),
|
||||||
|
color=alt.Color("label:N", title="Code"),
|
||||||
|
tooltip=["year", "hcpcs", "label", "non_fac_pe_rvu", "work_rvu", "total_nf_rvu"],
|
||||||
|
)
|
||||||
|
.properties(title="Practice Expense RVU Trajectory", width=700, height=350)
|
||||||
|
)
|
||||||
|
pe_chart
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. Implied budget neutrality tax ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 3. Implied Budget Neutrality Tax
|
||||||
|
|
||||||
|
When skin sub PE RVUs increase by Δ, **every other code's effective
|
||||||
|
payment decreases** proportionally. The "tax rate" is:
|
||||||
|
|
||||||
|
```
|
||||||
|
tax_rate = skin_sub_PE_delta / total_pool_PE
|
||||||
|
```
|
||||||
|
|
||||||
|
This table shows the year-over-year PE RVU increase for the 8 skin
|
||||||
|
sub codes, the total PE pool, and the implied compression on all
|
||||||
|
other codes.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(SKIN_CODES, q):
|
||||||
|
bn_tax = q(f"""
|
||||||
|
WITH yearly AS (
|
||||||
|
SELECT year,
|
||||||
|
sum(CASE WHEN hcpcs IN {SKIN_CODES} THEN non_fac_pe_rvu ELSE 0 END) as skin_pe,
|
||||||
|
sum(non_fac_pe_rvu) as total_pe,
|
||||||
|
sum(CASE WHEN hcpcs NOT IN {SKIN_CODES} THEN non_fac_pe_rvu ELSE 0 END) as other_pe
|
||||||
|
FROM pfs.rvu
|
||||||
|
WHERE mod IS NULL OR mod = ''
|
||||||
|
GROUP BY year
|
||||||
|
)
|
||||||
|
SELECT year,
|
||||||
|
round(skin_pe, 2) as skin_pe_rvu,
|
||||||
|
round(total_pe, 1) as total_pe_pool,
|
||||||
|
round(skin_pe - LAG(skin_pe) OVER (ORDER BY year), 2) as skin_pe_delta,
|
||||||
|
round(total_pe - LAG(total_pe) OVER (ORDER BY year), 1) as pool_pe_delta,
|
||||||
|
-- If skin PE grew and pool grew less, the difference is absorbed by others
|
||||||
|
round(CASE WHEN LAG(skin_pe) OVER (ORDER BY year) IS NOT NULL
|
||||||
|
THEN (skin_pe - LAG(skin_pe) OVER (ORDER BY year))
|
||||||
|
/ NULLIF(LAG(total_pe) OVER (ORDER BY year), 0) * 100
|
||||||
|
END, 4) as implied_tax_pct,
|
||||||
|
-- Dollar impact: tax_pct × average CF
|
||||||
|
round(CASE WHEN LAG(skin_pe) OVER (ORDER BY year) IS NOT NULL
|
||||||
|
THEN (skin_pe - LAG(skin_pe) OVER (ORDER BY year))
|
||||||
|
/ NULLIF(LAG(total_pe) OVER (ORDER BY year), 0) * 100
|
||||||
|
END, 4) as pct_compression
|
||||||
|
FROM yearly ORDER BY year
|
||||||
|
""")
|
||||||
|
bn_tax
|
||||||
|
return (bn_tax,)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. Which services bear the burden? ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 4. Which Services Bear the Burden?
|
||||||
|
|
||||||
|
Budget neutrality compression is proportional to each service
|
||||||
|
category's share of the PE pool. Categories with large PE shares
|
||||||
|
(surgery, cardiology) absorb more dollars even though the per-code
|
||||||
|
reduction is tiny.
|
||||||
|
|
||||||
|
The table shows: if the skin sub PE increase in the most recent year
|
||||||
|
were fully offset by compressing other categories, how much does
|
||||||
|
each category lose?
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(SKIN_CODES, alt, mo, q):
|
||||||
|
# Get the latest year's skin sub PE delta
|
||||||
|
skin_pe_delta = q(f"""
|
||||||
|
WITH yearly AS (
|
||||||
|
SELECT year, sum(non_fac_pe_rvu) as skin_pe
|
||||||
|
FROM pfs.rvu
|
||||||
|
WHERE hcpcs IN {SKIN_CODES} AND (mod IS NULL OR mod = '')
|
||||||
|
GROUP BY year
|
||||||
|
)
|
||||||
|
SELECT year, skin_pe,
|
||||||
|
skin_pe - LAG(skin_pe) OVER (ORDER BY year) as delta
|
||||||
|
FROM yearly ORDER BY year DESC LIMIT 1
|
||||||
|
""")
|
||||||
|
|
||||||
|
delta_val = skin_pe_delta.select("delta").item()
|
||||||
|
delta_year = skin_pe_delta.select("year").item()
|
||||||
|
|
||||||
|
category_impact = q(f"""
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN hcpcs BETWEEN '99201' AND '99499' THEN 'E/M'
|
||||||
|
WHEN hcpcs BETWEEN '10000' AND '19999' THEN 'Integumentary'
|
||||||
|
WHEN hcpcs BETWEEN '20000' AND '29999' THEN 'Musculoskeletal'
|
||||||
|
WHEN hcpcs BETWEEN '30000' AND '39999' THEN 'Resp/Cardiovascular'
|
||||||
|
WHEN hcpcs BETWEEN '40000' AND '49999' THEN 'Digestive'
|
||||||
|
WHEN hcpcs BETWEEN '50000' AND '59999' THEN 'Urinary/Genital'
|
||||||
|
WHEN hcpcs BETWEEN '60000' AND '69999' THEN 'Endocrine/Nervous'
|
||||||
|
WHEN hcpcs BETWEEN '70000' AND '79999' THEN 'Radiology'
|
||||||
|
WHEN hcpcs BETWEEN '80000' AND '89999' THEN 'Path/Lab'
|
||||||
|
WHEN hcpcs BETWEEN '90000' AND '99199' THEN 'Medicine'
|
||||||
|
WHEN hcpcs LIKE 'G%' THEN 'G-codes'
|
||||||
|
ELSE 'Other'
|
||||||
|
END as category,
|
||||||
|
count(*) as codes,
|
||||||
|
round(sum(non_fac_pe_rvu), 1) as category_pe,
|
||||||
|
round(100.0 * sum(non_fac_pe_rvu) /
|
||||||
|
NULLIF((SELECT sum(non_fac_pe_rvu) FROM pfs.rvu
|
||||||
|
WHERE year={delta_year} AND (mod IS NULL OR mod = '')
|
||||||
|
AND hcpcs NOT IN {SKIN_CODES}), 0), 2) as pe_share_pct,
|
||||||
|
-- Implied PE reduction absorbed by this category
|
||||||
|
round({delta_val} * sum(non_fac_pe_rvu) /
|
||||||
|
NULLIF((SELECT sum(non_fac_pe_rvu) FROM pfs.rvu
|
||||||
|
WHERE year={delta_year} AND (mod IS NULL OR mod = '')
|
||||||
|
AND hcpcs NOT IN {SKIN_CODES}), 0), 4) as implied_pe_loss
|
||||||
|
FROM pfs.rvu
|
||||||
|
WHERE year = {delta_year}
|
||||||
|
AND (mod IS NULL OR mod = '')
|
||||||
|
AND hcpcs NOT IN {SKIN_CODES}
|
||||||
|
GROUP BY category
|
||||||
|
ORDER BY category_pe DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
impact_chart = (
|
||||||
|
alt.Chart(category_impact.to_pandas())
|
||||||
|
.mark_bar()
|
||||||
|
.encode(
|
||||||
|
x=alt.X("implied_pe_loss:Q", title=f"Implied PE RVU Loss (from {delta_val:+.2f} skin sub PE delta)"),
|
||||||
|
y=alt.Y("category:N", title="", sort="-x"),
|
||||||
|
color=alt.Color("pe_share_pct:Q", title="PE Pool Share %",
|
||||||
|
scale=alt.Scale(scheme="reds")),
|
||||||
|
tooltip=["category", "codes", "category_pe", "pe_share_pct", "implied_pe_loss"],
|
||||||
|
)
|
||||||
|
.properties(
|
||||||
|
title=f"Budget Neutrality Burden by Service Category (CY{delta_year})",
|
||||||
|
width=700, height=350,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
mo.vstack([
|
||||||
|
mo.md(f"""
|
||||||
|
**CY{delta_year}:** Skin sub application codes gained **{delta_val:+.2f} PE RVUs**.
|
||||||
|
Under budget neutrality, this is redistributed across ~{category_impact.select('codes').sum().item():,} other codes
|
||||||
|
proportional to their PE share.
|
||||||
|
"""),
|
||||||
|
impact_chart,
|
||||||
|
category_impact,
|
||||||
|
])
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5. Conversion factor erosion ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 5. Conversion Factor Erosion
|
||||||
|
|
||||||
|
The conversion factor has declined from $36.09 (2020) to $32.35
|
||||||
|
(2025–2026). While this is driven primarily by MACRA spending
|
||||||
|
targets, **RVU pool growth contributes to the pressure.** When total
|
||||||
|
unweighted RVUs grow faster than allowed spending, the CF must
|
||||||
|
decline to maintain budget neutrality.
|
||||||
|
|
||||||
|
This chart overlays the CF trajectory with the total RVU pool
|
||||||
|
growth to show the inverse relationship.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(RULES, alt, pl, q):
|
||||||
|
cf_data = pl.DataFrame({
|
||||||
|
"year": list(RULES.keys()),
|
||||||
|
"conversion_factor": [r.conversion_factor for r in RULES.values()],
|
||||||
|
})
|
||||||
|
|
||||||
|
pool_growth = q("""
|
||||||
|
SELECT year, round(sum(work_rvu + non_fac_pe_rvu + mp_rvu), 0) as total_rvu
|
||||||
|
FROM pfs.rvu WHERE mod IS NULL OR mod = ''
|
||||||
|
GROUP BY year ORDER BY year
|
||||||
|
""")
|
||||||
|
|
||||||
|
combined = cf_data.join(pool_growth, on="year", how="inner")
|
||||||
|
|
||||||
|
cf_line = (
|
||||||
|
alt.Chart(combined.to_pandas())
|
||||||
|
.mark_line(point=True, color="#1f77b4")
|
||||||
|
.encode(
|
||||||
|
x=alt.X("year:O", title="Year"),
|
||||||
|
y=alt.Y("conversion_factor:Q", title="Conversion Factor ($)",
|
||||||
|
scale=alt.Scale(zero=False)),
|
||||||
|
tooltip=["year", "conversion_factor", "total_rvu"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rvu_line = (
|
||||||
|
alt.Chart(combined.to_pandas())
|
||||||
|
.mark_line(point=True, color="#d62728", strokeDash=[4, 4])
|
||||||
|
.encode(
|
||||||
|
x=alt.X("year:O"),
|
||||||
|
y=alt.Y("total_rvu:Q", title="Total Unweighted RVU Pool",
|
||||||
|
scale=alt.Scale(zero=False)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
cf_chart = (
|
||||||
|
alt.layer(cf_line, rvu_line)
|
||||||
|
.resolve_scale(y="independent")
|
||||||
|
.properties(title="Conversion Factor vs. RVU Pool Growth", width=700, height=350)
|
||||||
|
)
|
||||||
|
cf_chart
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6. Per-code dollar impact ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 6. Dollar Impact on Common Services
|
||||||
|
|
||||||
|
How much does a typical office visit, imaging study, or surgical
|
||||||
|
procedure lose when skin sub PE RVUs increase? This table shows
|
||||||
|
the implied payment reduction for commonly billed codes.
|
||||||
|
|
||||||
|
The calculation: if skin sub PE grows by Δ and the total PE pool
|
||||||
|
is P, then each other code's PE is effectively reduced by
|
||||||
|
`code_PE × (Δ / P)`, and payment drops by that × CF.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(RULES, SKIN_CODES, mo, q):
|
||||||
|
|
||||||
|
latest_year = max(RULES.keys())
|
||||||
|
cf = RULES[latest_year].conversion_factor
|
||||||
|
|
||||||
|
# Latest skin PE delta
|
||||||
|
skin_delta = q(f"""
|
||||||
|
WITH yearly AS (
|
||||||
|
SELECT year, sum(non_fac_pe_rvu) as skin_pe
|
||||||
|
FROM pfs.rvu WHERE hcpcs IN {SKIN_CODES} AND (mod IS NULL OR mod = '')
|
||||||
|
GROUP BY year
|
||||||
|
)
|
||||||
|
SELECT skin_pe - LAG(skin_pe) OVER (ORDER BY year) as delta
|
||||||
|
FROM yearly ORDER BY year DESC LIMIT 1
|
||||||
|
""").item()
|
||||||
|
|
||||||
|
total_pe = q(f"""
|
||||||
|
SELECT sum(non_fac_pe_rvu) FROM pfs.rvu
|
||||||
|
WHERE year = {latest_year} AND (mod IS NULL OR mod = '')
|
||||||
|
AND hcpcs NOT IN {SKIN_CODES}
|
||||||
|
""").item()
|
||||||
|
|
||||||
|
tax_rate = skin_delta / total_pe if total_pe else 0
|
||||||
|
|
||||||
|
common_codes = q(f"""
|
||||||
|
SELECT hcpcs, description,
|
||||||
|
non_fac_pe_rvu,
|
||||||
|
work_rvu,
|
||||||
|
mp_rvu,
|
||||||
|
non_fac_pe_rvu + work_rvu + mp_rvu as total_rvu,
|
||||||
|
round(non_fac_pe_rvu * {tax_rate}, 6) as pe_rvu_loss,
|
||||||
|
round(non_fac_pe_rvu * {tax_rate} * {cf}, 4) as dollar_loss
|
||||||
|
FROM pfs.rvu
|
||||||
|
WHERE year = {latest_year}
|
||||||
|
AND (mod IS NULL OR mod = '')
|
||||||
|
AND hcpcs IN ('99213','99214','99215',
|
||||||
|
'99203','99204','99205',
|
||||||
|
'27447','27130',
|
||||||
|
'43239','45380',
|
||||||
|
'93000','93306',
|
||||||
|
'77067','74177',
|
||||||
|
'36415','85025',
|
||||||
|
'90834','90837',
|
||||||
|
'17000','11102')
|
||||||
|
ORDER BY dollar_loss
|
||||||
|
""")
|
||||||
|
|
||||||
|
mo.vstack([
|
||||||
|
mo.md(f"""
|
||||||
|
**CY{latest_year}** parameters:
|
||||||
|
- Skin sub PE delta: **{skin_delta:+.2f} RVUs**
|
||||||
|
- Other-code PE pool: **{total_pe:,.1f} RVUs**
|
||||||
|
- Implied tax rate: **{tax_rate*100:.4f}%** of each code's PE
|
||||||
|
- CF: **${cf}**
|
||||||
|
"""),
|
||||||
|
common_codes,
|
||||||
|
])
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ── 7. Cumulative tax since 2015 ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 7. Cumulative Structural Tax Since 2015
|
||||||
|
|
||||||
|
The skin sub application codes' total PE RVUs have grown from
|
||||||
|
16.45 (2015) to 22.05 (2026). Under budget neutrality, this
|
||||||
|
5.60 RVU increase must come from somewhere.
|
||||||
|
|
||||||
|
This chart shows the cumulative PE RVU "withdrawn" from the
|
||||||
|
rest of the fee schedule by the growth in skin sub PE.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(SKIN_CODES, alt, q):
|
||||||
|
cumulative = q(f"""
|
||||||
|
WITH yearly AS (
|
||||||
|
SELECT year,
|
||||||
|
sum(CASE WHEN hcpcs IN {SKIN_CODES} THEN non_fac_pe_rvu ELSE 0 END) as skin_pe,
|
||||||
|
sum(non_fac_pe_rvu) as total_pe
|
||||||
|
FROM pfs.rvu WHERE mod IS NULL OR mod = ''
|
||||||
|
GROUP BY year
|
||||||
|
)
|
||||||
|
SELECT year,
|
||||||
|
round(skin_pe, 2) as skin_pe,
|
||||||
|
round(skin_pe - FIRST_VALUE(skin_pe) OVER (ORDER BY year), 2) as cumulative_pe_growth,
|
||||||
|
round(total_pe, 1) as total_pe
|
||||||
|
FROM yearly ORDER BY year
|
||||||
|
""")
|
||||||
|
|
||||||
|
cum_chart = (
|
||||||
|
alt.Chart(cumulative.to_pandas())
|
||||||
|
.mark_area(opacity=0.3, color="#d62728")
|
||||||
|
.encode(
|
||||||
|
x=alt.X("year:O", title="Year"),
|
||||||
|
y=alt.Y("cumulative_pe_growth:Q",
|
||||||
|
title="Cumulative Skin Sub PE Growth (RVUs above 2015 baseline)"),
|
||||||
|
tooltip=["year", "skin_pe", "cumulative_pe_growth", "total_pe"],
|
||||||
|
)
|
||||||
|
) + (
|
||||||
|
alt.Chart(cumulative.to_pandas())
|
||||||
|
.mark_line(point=True, color="#d62728")
|
||||||
|
.encode(
|
||||||
|
x="year:O",
|
||||||
|
y="cumulative_pe_growth:Q",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
cum_chart_final = cum_chart.properties(
|
||||||
|
title="Cumulative PE RVU Growth — Skin Sub Application Codes vs. 2015 Baseline",
|
||||||
|
width=700, height=300,
|
||||||
|
)
|
||||||
|
cum_chart_final
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ── 8. Key findings ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 8. Key Findings
|
||||||
|
|
||||||
|
1. **Structural tax is real but small per-code.** The unweighted RVU
|
||||||
|
share of skin sub application codes is ~0.1% of the pool.
|
||||||
|
Per-code compression is fractions of a cent.
|
||||||
|
|
||||||
|
2. **Volume is the amplifier.** The structural RVU analysis
|
||||||
|
understates the true impact because it ignores utilization.
|
||||||
|
OIG documented explosive volume growth in skin sub claims — when
|
||||||
|
frequency-weighted, these 8 codes consume a much larger share of
|
||||||
|
aggregate spending than their unweighted RVUs suggest.
|
||||||
|
|
||||||
|
3. **PE is the battleground.** Work RVUs for 15271–15278 have been
|
||||||
|
stable (13.46 total for 10 years). PE RVUs grew from 16.45 to
|
||||||
|
22.05 (+34%). The CY2022 clinical labor rate update was a major
|
||||||
|
driver.
|
||||||
|
|
||||||
|
4. **CY2026 reclassification shifts the tax.** Moving skin subs
|
||||||
|
from ASP + 6% (OPPS) to flat $127.28/cm² doesn't directly
|
||||||
|
affect the PFS budget neutrality pool — but it does change
|
||||||
|
the volume incentives that drive utilization of application
|
||||||
|
codes 15271–15278.
|
||||||
|
|
||||||
|
5. **The real cost is in the product, not the application.** For a
|
||||||
|
25cm² wound, the application fee (~$140) is dwarfed by the
|
||||||
|
product cost (often >$5,000). Budget neutrality only governs
|
||||||
|
the application fee; the product cost is outside the PFS.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run()
|
||||||
512
notebooks/skin_sub_cost_sharing.py
Normal file
512
notebooks/skin_sub_cost_sharing.py
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
import marimo
|
||||||
|
|
||||||
|
__generated_with = "0.21.1"
|
||||||
|
app = marimo.App(width="medium")
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _():
|
||||||
|
import marimo as mo
|
||||||
|
|
||||||
|
return (mo,)
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
# Beneficiary Cost Sharing for Skin Substitutes
|
||||||
|
|
||||||
|
Medicare Part B beneficiaries pay **20% coinsurance** on both the
|
||||||
|
application procedure (PFS) and the product (ASP + 6%). This
|
||||||
|
notebook tracks how cost sharing has changed over time and how
|
||||||
|
the CY2026 reclassification affects out-of-pocket exposure.
|
||||||
|
|
||||||
|
**Cost-sharing components:**
|
||||||
|
- **Application fee**: 20% of PFS carrier locality fee (15271–15278)
|
||||||
|
- **Product cost**: 20% of ASP + 6% payment limit × units
|
||||||
|
- **OPPS copayment**: minimum unadjusted copayment per APC (hospital outpatient)
|
||||||
|
- **Limiting charge**: non-participating providers may charge up to 115% of fee schedule
|
||||||
|
|
||||||
|
**CY2026 change:** All skin subs move to a flat $127.28/cm². OPPS copay
|
||||||
|
drops from ~$165–$366 per code to $25.43 flat. Product coinsurance
|
||||||
|
collapses to $25.43/unit regardless of actual ASP.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _():
|
||||||
|
import altair as alt
|
||||||
|
import polars as pl
|
||||||
|
from conf import connect
|
||||||
|
from pfs.rules import RULES
|
||||||
|
|
||||||
|
con = connect.duckdb()
|
||||||
|
|
||||||
|
def q(sql):
|
||||||
|
return con.execute(sql).pl()
|
||||||
|
|
||||||
|
SKIN_CODES = "('15271','15272','15273','15274','15275','15276','15277','15278')"
|
||||||
|
|
||||||
|
# Part B deductible history (published by CMS annually)
|
||||||
|
DEDUCTIBLES = {
|
||||||
|
2015: 147.00, 2016: 166.00, 2017: 183.00, 2018: 183.00,
|
||||||
|
2019: 185.00, 2020: 198.00, 2021: 203.00, 2022: 233.00,
|
||||||
|
2023: 226.00, 2024: 240.00, 2025: 257.00, 2026: 257.00,
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEDUCTIBLES, RULES, SKIN_CODES, alt, con, pl, q
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. Application fee coinsurance over time ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 1. Application Fee Coinsurance by Region
|
||||||
|
|
||||||
|
The beneficiary pays 20% of the PFS-approved amount for the
|
||||||
|
application procedure. This varies by locality and year.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(alt, q):
|
||||||
|
regions = [
|
||||||
|
"MANHATTAN", "REST OF FLORIDA", "REST OF TEXAS",
|
||||||
|
"REST OF CALIFORNIA", "SOUTH CAROLINA",
|
||||||
|
]
|
||||||
|
region_list = ", ".join(f"'{r}'" for r in regions)
|
||||||
|
|
||||||
|
app_coinsurance = q(f"""
|
||||||
|
SELECT c.year, g.locality_name,
|
||||||
|
c.non_fac_fee,
|
||||||
|
round(c.non_fac_fee * 0.20, 2) as bene_coinsurance,
|
||||||
|
c.non_fac_limiting_charge,
|
||||||
|
round(c.non_fac_limiting_charge * 0.20, 2) as bene_limiting_coinsurance
|
||||||
|
FROM pfs.carrier_locality c
|
||||||
|
JOIN pfs.gpci g ON c.mac = g.mac AND c.locality = g.locality AND c.year = g.year
|
||||||
|
WHERE c.hcpcs = '15271'
|
||||||
|
AND g.locality_name IN ({region_list})
|
||||||
|
ORDER BY c.year, g.locality_name
|
||||||
|
""")
|
||||||
|
|
||||||
|
app_chart = (
|
||||||
|
alt.Chart(app_coinsurance.to_pandas())
|
||||||
|
.mark_line(point=True)
|
||||||
|
.encode(
|
||||||
|
x=alt.X("year:O", title="Year"),
|
||||||
|
y=alt.Y("bene_coinsurance:Q", title="Beneficiary Coinsurance ($)"),
|
||||||
|
color=alt.Color("locality_name:N", title="Region"),
|
||||||
|
tooltip=["year", "locality_name", "non_fac_fee",
|
||||||
|
"bene_coinsurance", "non_fac_limiting_charge"],
|
||||||
|
)
|
||||||
|
.properties(
|
||||||
|
title="15271 Application — Beneficiary 20% Coinsurance by Region",
|
||||||
|
width=700, height=350,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
app_chart
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. Product coinsurance trajectory ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 2. Product Coinsurance Over Time (Per Unit)
|
||||||
|
|
||||||
|
The beneficiary pays 20% of the ASP + 6% payment limit **per cm²**.
|
||||||
|
For high-ASP products, this adds up fast — a 25cm² application of
|
||||||
|
a $150/cm² product means $750 in coinsurance just for the product.
|
||||||
|
|
||||||
|
The red dashed line shows the CY2026 flat-rate coinsurance:
|
||||||
|
20% × $127.28 = **$25.46/cm²**.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(alt, pl, q):
|
||||||
|
product_coins = q("""
|
||||||
|
SELECT quarter, hcpcs_code, short_description,
|
||||||
|
payment_limit,
|
||||||
|
round(payment_limit * 0.20, 2) as bene_per_unit
|
||||||
|
FROM skin_subs.asp_quarterly
|
||||||
|
WHERE hcpcs_code IN ('Q4101','Q4186','Q4132','Q4116','Q4100')
|
||||||
|
ORDER BY quarter, hcpcs_code
|
||||||
|
""")
|
||||||
|
|
||||||
|
flat_coins = (
|
||||||
|
alt.Chart(pl.DataFrame({"y": [127.28 * 0.20]}).to_pandas())
|
||||||
|
.mark_rule(color="red", strokeDash=[4, 4], strokeWidth=2)
|
||||||
|
.encode(y="y:Q")
|
||||||
|
)
|
||||||
|
|
||||||
|
product_lines = (
|
||||||
|
alt.Chart(product_coins.to_pandas())
|
||||||
|
.mark_line()
|
||||||
|
.encode(
|
||||||
|
x=alt.X("quarter:O", title="Quarter",
|
||||||
|
axis=alt.Axis(labelAngle=-45, labelFontSize=8)),
|
||||||
|
y=alt.Y("bene_per_unit:Q", title="Beneficiary Coinsurance per cm² ($)"),
|
||||||
|
color=alt.Color("short_description:N", title="Product"),
|
||||||
|
tooltip=["quarter", "hcpcs_code", "short_description",
|
||||||
|
"payment_limit", "bene_per_unit"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
product_chart = (
|
||||||
|
(product_lines + flat_coins)
|
||||||
|
.properties(
|
||||||
|
title="Product Coinsurance per Unit (20% of ASP + 6%)",
|
||||||
|
width=700, height=400,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
product_chart
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. Total episode cost sharing ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 3. Total Episode Cost Sharing (25cm² Wound)
|
||||||
|
|
||||||
|
For a typical 25cm² wound treated in an office setting, the
|
||||||
|
beneficiary's total out-of-pocket is:
|
||||||
|
|
||||||
|
```
|
||||||
|
total = 20% × application_fee + 20% × (ASP+6% × 25 units)
|
||||||
|
```
|
||||||
|
|
||||||
|
This chart shows how that total has changed over time for
|
||||||
|
selected products, with the post-2026 flat-rate equivalent shown.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(alt, pl, q):
|
||||||
|
# Use Q1 of each year for annual comparison
|
||||||
|
episode_sharing = q("""
|
||||||
|
WITH asp_annual AS (
|
||||||
|
SELECT CAST(substr(quarter, 1, 4) AS INTEGER) as year,
|
||||||
|
hcpcs_code, short_description,
|
||||||
|
payment_limit
|
||||||
|
FROM skin_subs.asp_quarterly
|
||||||
|
WHERE substr(quarter, 6, 2) = 'Q1'
|
||||||
|
AND hcpcs_code IN ('Q4101','Q4186','Q4132','Q4116')
|
||||||
|
),
|
||||||
|
app_fee AS (
|
||||||
|
SELECT c.year, avg(c.non_fac_fee) as avg_app_fee
|
||||||
|
FROM pfs.carrier_locality c
|
||||||
|
WHERE c.hcpcs = '15271'
|
||||||
|
GROUP BY c.year
|
||||||
|
)
|
||||||
|
SELECT a.year, a.hcpcs_code, a.short_description,
|
||||||
|
round(a.payment_limit, 2) as asp_per_unit,
|
||||||
|
round(f.avg_app_fee, 2) as avg_app_fee,
|
||||||
|
round(f.avg_app_fee * 0.20, 2) as app_coinsurance,
|
||||||
|
round(a.payment_limit * 25 * 0.20, 2) as product_coinsurance_25cm,
|
||||||
|
round(f.avg_app_fee * 0.20 + a.payment_limit * 25 * 0.20, 2) as total_bene_cost
|
||||||
|
FROM asp_annual a
|
||||||
|
LEFT JOIN app_fee f ON a.year = f.year
|
||||||
|
ORDER BY a.year, a.hcpcs_code
|
||||||
|
""")
|
||||||
|
|
||||||
|
flat_episode = 127.28 * 25 * 0.20 # $636.40 product + ~$28 application
|
||||||
|
|
||||||
|
flat_line = (
|
||||||
|
alt.Chart(pl.DataFrame({"y": [flat_episode]}).to_pandas())
|
||||||
|
.mark_rule(color="red", strokeDash=[4, 4], strokeWidth=2)
|
||||||
|
.encode(y="y:Q")
|
||||||
|
)
|
||||||
|
|
||||||
|
episode_lines = (
|
||||||
|
alt.Chart(episode_sharing.to_pandas())
|
||||||
|
.mark_line(point=True)
|
||||||
|
.encode(
|
||||||
|
x=alt.X("year:O", title="Year"),
|
||||||
|
y=alt.Y("total_bene_cost:Q",
|
||||||
|
title="Beneficiary Total Cost Sharing ($)"),
|
||||||
|
color=alt.Color("short_description:N", title="Product"),
|
||||||
|
tooltip=["year", "hcpcs_code", "short_description",
|
||||||
|
"asp_per_unit", "app_coinsurance",
|
||||||
|
"product_coinsurance_25cm", "total_bene_cost"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
episode_chart = (
|
||||||
|
(episode_lines + flat_line)
|
||||||
|
.properties(
|
||||||
|
title="Total Beneficiary Cost Sharing — 25cm² Wound Episode",
|
||||||
|
width=700, height=400,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
episode_chart
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. OPPS copayment collapse ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 4. OPPS Copayment Collapse in CY2026
|
||||||
|
|
||||||
|
In the hospital outpatient setting (HOPD), beneficiaries pay a
|
||||||
|
minimum unadjusted copayment per APC. For skin substitutes:
|
||||||
|
|
||||||
|
- **2021–2025**: Copays ranged from $105–$366 depending on the
|
||||||
|
product's APC assignment (high-cost vs. low-cost categories)
|
||||||
|
- **CY2026**: All products reclassified to a single flat rate
|
||||||
|
with copay of **$25.43** — a >85% reduction
|
||||||
|
|
||||||
|
This is the most dramatic beneficiary cost-sharing change in
|
||||||
|
the CY2026 reclassification.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(alt, q):
|
||||||
|
opps_copay = q("""
|
||||||
|
SELECT year,
|
||||||
|
count(*) as products,
|
||||||
|
round(avg(minimum_unadjusted_copayment), 2) as avg_copay,
|
||||||
|
round(min(minimum_unadjusted_copayment), 2) as min_copay,
|
||||||
|
round(max(minimum_unadjusted_copayment), 2) as max_copay,
|
||||||
|
round(max(minimum_unadjusted_copayment) -
|
||||||
|
min(minimum_unadjusted_copayment), 2) as copay_spread
|
||||||
|
FROM opps.skin_sub_addendum_b
|
||||||
|
WHERE minimum_unadjusted_copayment > 0
|
||||||
|
GROUP BY year ORDER BY year
|
||||||
|
""")
|
||||||
|
|
||||||
|
copay_chart = (
|
||||||
|
alt.Chart(opps_copay.to_pandas())
|
||||||
|
.mark_bar()
|
||||||
|
.encode(
|
||||||
|
x=alt.X("year:O", title="Year"),
|
||||||
|
y=alt.Y("avg_copay:Q", title="Average OPPS Copayment ($)"),
|
||||||
|
color=alt.condition(
|
||||||
|
alt.datum.year == 2026,
|
||||||
|
alt.value("#2ca02c"),
|
||||||
|
alt.value("#1f77b4"),
|
||||||
|
),
|
||||||
|
tooltip=["year", "products", "avg_copay", "min_copay",
|
||||||
|
"max_copay", "copay_spread"],
|
||||||
|
)
|
||||||
|
.properties(
|
||||||
|
title="OPPS Minimum Unadjusted Copayment — Skin Substitutes",
|
||||||
|
width=700, height=300,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
mo.vstack([copay_chart, opps_copay])
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5. Winners and losers: beneficiary perspective ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 5. Beneficiary Impact: Who Pays More, Who Pays Less?
|
||||||
|
|
||||||
|
Under the flat rate, beneficiaries using **high-ASP products**
|
||||||
|
(EpiFix, GrafixCore) see their per-unit coinsurance drop
|
||||||
|
dramatically. Those using **low-ASP products** (Apligraf at
|
||||||
|
~$30/unit) see coinsurance rise from ~$6 to ~$25.
|
||||||
|
|
||||||
|
This is a wealth transfer: beneficiaries who previously used
|
||||||
|
expensive products benefit; those with cheaper products pay more.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(alt, q):
|
||||||
|
bene_impact = q("""
|
||||||
|
SELECT hcpcs_code, short_description,
|
||||||
|
payment_limit as current_asp_payment,
|
||||||
|
round(payment_limit * 0.20, 2) as current_bene_per_unit,
|
||||||
|
127.28 as flat_rate,
|
||||||
|
round(127.28 * 0.20, 2) as flat_bene_per_unit,
|
||||||
|
round(127.28 * 0.20 - payment_limit * 0.20, 2) as bene_delta_per_unit,
|
||||||
|
CASE
|
||||||
|
WHEN payment_limit * 0.20 > 127.28 * 0.20 THEN 'bene saves'
|
||||||
|
WHEN payment_limit * 0.20 < 127.28 * 0.20 THEN 'bene pays more'
|
||||||
|
ELSE 'neutral'
|
||||||
|
END as bene_impact
|
||||||
|
FROM skin_subs.asp_quarterly
|
||||||
|
WHERE quarter = (SELECT max(quarter) FROM skin_subs.asp_quarterly)
|
||||||
|
ORDER BY bene_delta_per_unit
|
||||||
|
""")
|
||||||
|
|
||||||
|
bene_chart = (
|
||||||
|
alt.Chart(bene_impact.to_pandas())
|
||||||
|
.mark_bar()
|
||||||
|
.encode(
|
||||||
|
x=alt.X("bene_delta_per_unit:Q",
|
||||||
|
title="Change in Beneficiary Coinsurance per Unit ($)"),
|
||||||
|
y=alt.Y("short_description:N", title="", sort="x",
|
||||||
|
axis=alt.Axis(labelLimit=300)),
|
||||||
|
color=alt.Color("bene_impact:N",
|
||||||
|
scale=alt.Scale(
|
||||||
|
domain=["bene saves", "bene pays more", "neutral"],
|
||||||
|
range=["#2ca02c", "#d62728", "#7f7f7f"],
|
||||||
|
),
|
||||||
|
title="Impact"),
|
||||||
|
tooltip=["hcpcs_code", "short_description",
|
||||||
|
"current_bene_per_unit", "flat_bene_per_unit",
|
||||||
|
"bene_delta_per_unit"],
|
||||||
|
)
|
||||||
|
.properties(
|
||||||
|
title="CY2026 Beneficiary Coinsurance Change per Unit",
|
||||||
|
width=700,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bene_chart
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6. Episode scenario comparison ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 6. Episode Scenarios: Before vs. After CY2026
|
||||||
|
|
||||||
|
Three wound scenarios showing the complete beneficiary cost
|
||||||
|
breakdown before and after the flat-rate reclassification.
|
||||||
|
All assume office setting (POS 11), participating provider,
|
||||||
|
national average application fee.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(pl, q):
|
||||||
|
# Get average national app fee for 2025
|
||||||
|
avg_fee = q("""
|
||||||
|
SELECT round(avg(non_fac_fee), 2) as avg_fee
|
||||||
|
FROM pfs.carrier_locality
|
||||||
|
WHERE year = 2025 AND hcpcs = '15271'
|
||||||
|
""").item()
|
||||||
|
|
||||||
|
scenarios = pl.DataFrame({
|
||||||
|
"scenario": [
|
||||||
|
"Small wound (10cm²) — Q4101 Apligraf",
|
||||||
|
"Medium wound (25cm²) — Q4186 EpiFix",
|
||||||
|
"Large wound (50cm²) — Q4132 GrafixCore",
|
||||||
|
],
|
||||||
|
"units": [10, 25, 50],
|
||||||
|
"asp_per_unit": [30.23, 151.17, 106.70],
|
||||||
|
"product_name": ["Apligraf", "EpiFix", "GrafixCore"],
|
||||||
|
}).with_columns(
|
||||||
|
# Pre-2026: ASP + 6%
|
||||||
|
(pl.col("asp_per_unit") * pl.col("units") * 0.20).round(2).alias("pre_product_coins"),
|
||||||
|
pl.lit(avg_fee * 0.20).round(2).alias("pre_app_coins"),
|
||||||
|
# Post-2026: flat $127.28
|
||||||
|
(pl.lit(127.28) * pl.col("units") * 0.20).round(2).alias("post_product_coins"),
|
||||||
|
pl.lit(avg_fee * 0.20).round(2).alias("post_app_coins"),
|
||||||
|
).with_columns(
|
||||||
|
(pl.col("pre_product_coins") + pl.col("pre_app_coins")).alias("pre_total"),
|
||||||
|
(pl.col("post_product_coins") + pl.col("post_app_coins")).alias("post_total"),
|
||||||
|
).with_columns(
|
||||||
|
(pl.col("post_total") - pl.col("pre_total")).round(2).alias("delta"),
|
||||||
|
)
|
||||||
|
scenarios
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ── 7. Part B deductible context ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 7. Part B Deductible Context
|
||||||
|
|
||||||
|
Before coinsurance applies, beneficiaries must meet the annual
|
||||||
|
Part B deductible. A single skin substitute episode can exceed
|
||||||
|
the entire deductible, meaning the full coinsurance amount is
|
||||||
|
additional out-of-pocket cost for most beneficiaries.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(DEDUCTIBLES, alt, pl):
|
||||||
|
deductible_df = pl.DataFrame({
|
||||||
|
"year": list(DEDUCTIBLES.keys()),
|
||||||
|
"deductible": list(DEDUCTIBLES.values()),
|
||||||
|
})
|
||||||
|
|
||||||
|
ded_chart = (
|
||||||
|
alt.Chart(deductible_df.to_pandas())
|
||||||
|
.mark_bar(color="#ff7f0e")
|
||||||
|
.encode(
|
||||||
|
x=alt.X("year:O", title="Year"),
|
||||||
|
y=alt.Y("deductible:Q", title="Annual Part B Deductible ($)"),
|
||||||
|
tooltip=["year", "deductible"],
|
||||||
|
)
|
||||||
|
.properties(
|
||||||
|
title="Medicare Part B Annual Deductible",
|
||||||
|
width=700, height=250,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ded_chart
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ── 8. Key takeaways ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.cell(hide_code=True)
|
||||||
|
def _(mo):
|
||||||
|
mo.md("""
|
||||||
|
## 8. Key Takeaways
|
||||||
|
|
||||||
|
1. **OPPS copay drops >85% in CY2026.** From $105–$366 per code
|
||||||
|
to $25.43 flat. This is the single largest beneficiary-facing
|
||||||
|
change in the reclassification.
|
||||||
|
|
||||||
|
2. **High-ASP product users save significantly.** For EpiFix
|
||||||
|
(Q4186, ~$151/unit), coinsurance per cm² drops from ~$30 to
|
||||||
|
~$25 — but for a 25cm² wound, the total product coinsurance
|
||||||
|
drops from ~$755 to ~$636 ($119 savings).
|
||||||
|
|
||||||
|
3. **Low-ASP product users pay more.** For Apligraf (Q4101,
|
||||||
|
~$30/unit), per-unit coinsurance rises from ~$6 to ~$25.
|
||||||
|
A 25cm² wound goes from ~$30 to ~$636 in product coinsurance.
|
||||||
|
|
||||||
|
4. **Application fee coinsurance is stable.** The PFS component
|
||||||
|
(~$27–$35 for 15271 depending on locality) is a small fraction
|
||||||
|
of total cost sharing and relatively stable over time.
|
||||||
|
|
||||||
|
5. **Deductible is a floor, not a ceiling.** The $257 Part B
|
||||||
|
deductible (2025–2026) is typically exceeded by a single skin
|
||||||
|
sub episode, so coinsurance applies to the full amount.
|
||||||
|
|
||||||
|
6. **No Medigap/supplement analysis.** Most beneficiaries have
|
||||||
|
supplemental coverage (Medigap, employer, Medicaid dual) that
|
||||||
|
covers the 20% coinsurance. Actual out-of-pocket may be lower
|
||||||
|
depending on coverage type.
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run()
|
||||||
Reference in New Issue
Block a user