feat: skin sub budget neutrality notebook — PFS cross-service impact
Some checks failed
CI / skinny-install (aco) (push) Successful in 48s
CI / lint-test (push) Successful in 1m13s
CI / skinny-install (api) (push) Successful in 28s
CI / skinny-install (bcda) (push) Successful in 36s
CI / skinny-install (ccw) (push) Successful in 32s
CI / skinny-install (cli) (push) Successful in 29s
CI / skinny-install (cms) (push) Successful in 29s
CI / skinny-install (opps) (push) Successful in 29s
CI / skinny-install (perf) (push) Successful in 30s
CI / skinny-install (pfs) (push) Successful in 34s
Infra CI / notebooks (push) Successful in 6s
CI / skinny-install (rex) (push) Successful in 26s
Infra CI / docs (push) Failing after 6s
Infra CI / api (push) Successful in 5s
Infra CI / mc (push) Successful in 5s
CI / skinny-install (aco) (pull_request) Successful in 50s
CI / lint-test (pull_request) Successful in 1m17s
CI / skinny-install (api) (pull_request) Successful in 26s
CI / skinny-install (bib) (pull_request) Successful in 33s
CI / skinny-install (bcda) (pull_request) Successful in 36s
CI / skinny-install (cms) (pull_request) Successful in 30s
CI / skinny-install (pfs) (pull_request) Successful in 30s
Infra CI / notebooks (pull_request) Successful in 6s
Infra CI / docs (pull_request) Failing after 8s
Infra CI / api (pull_request) Successful in 6s
CI / skinny-install (bib) (push) Successful in 31s
CI / skinny-install (bls) (push) Successful in 29s
CI / skinny-install (conf) (push) Successful in 29s
Infra CI / zotero (push) Successful in 6s
CI / skinny-install (bls) (pull_request) Successful in 24s
CI / skinny-install (ccw) (pull_request) Successful in 35s
CI / skinny-install (cli) (pull_request) Successful in 36s
CI / skinny-install (conf) (pull_request) Successful in 29s
CI / skinny-install (opps) (pull_request) Successful in 35s
CI / skinny-install (perf) (pull_request) Successful in 31s
Infra CI / zotero (pull_request) Successful in 9s
CI / skinny-install (rex) (pull_request) Successful in 30s
Infra CI / mc (pull_request) Successful in 6s

Analyses how skin substitute application code (15271-15278) RVU growth
redistributes payment away from other services under PFS budget
neutrality.

8 sections:
1. Skin sub share of unweighted RVU pool over time
2. PE RVU revaluation trajectory (PE grew 34% while Work stayed flat)
3. Implied budget neutrality tax rate per year
4. Service category burden (which specialties absorb the compression)
5. Conversion factor erosion vs RVU pool growth
6. Per-code dollar impact on common services (99213, 27447, etc.)
7. Cumulative PE RVU structural tax since 2015
8. Key findings (volume amplifier, PE battleground, CY2026 shift)

refs #246
This commit is contained in:
kert
2026-03-26 22:37:44 -04:00
parent 240cba5186
commit 0d6e191fa4

View 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()