Medicare Part B Oncology Drug Spending, 2020–2024¶
Personal portfolio project by Mohammad Ferdaws Homayoon, using public CMS data. It isn't Evolent work and doesn't use any Evolent or patient-level data.
1. Question¶
A specialty benefit manager in oncology has to know where provider-administered cancer-drug spending is concentrated, how fast it is growing, and whether that growth comes from price or volume. It also has to know where biosimilars could lower cost. This notebook answers those questions with Medicare Part B fee-for-service data.
2. Data¶
- Source: CMS, Medicare Part B Spending by Drug, data year 2024 release (covers 2020–2024). Dataset page: https://data.cms.gov/summary-statistics-on-use-and-payments/medicare-medicaid-spending-by-drug/medicare-part-b-spending-by-drug
- File:
DSD_PTB_RY26_P06_V10_DYT24_HCPCS.csv, downloaded from https://data.cms.gov/sites/default/files/2026-06/6616fc91-eb70-404c-9f27-1448e47a3a73/DSD_PTB_RY26_P06_V10_DYT24_HCPCS-%20260602.csv - Grain: one row per HCPCS drug code. It has total spending, dosage units, claims, and beneficiaries for each year.
3. Method¶
- Oncology scope: I counted HCPCS J9000–J9999 (the chemotherapy and antineoplastic drug code range). I also added the Q-code oncology biosimilars for bevacizumab, trastuzumab, and rituximab, plus the Q-coded CAR-T and cell therapies (Q2041, Q2042, Q2043, Q2053–Q2056). Supportive-care drugs such as G-CSFs, denosumab, ESAs, and mesna are left out. So are non-cancer drugs billed under J9 codes: Vyvgart, Vyvgart Hytrulo, and Rystiggo (myasthenia gravis), Tzield (type 1 diabetes), and Gamifant (HLH).
- Growth decomposition: I split each drug's spending growth into price (average spending per dosage unit) and volume (dosage units).
- Biosimilars: for each molecule I compared the reference product with its biosimilars. Each uses the same 10 mg billing unit. I looked at share of spending and average spending per unit.
In [ ]:
import pandas as pd, numpy as np, matplotlib.pyplot as plt, matplotlib.ticker as mt
pd.set_option('display.float_format', lambda x: f'{x:,.2f}')
raw = pd.read_csv('data/partb_2024.csv')
df = raw.copy()
for c in df.columns[4:]:
df[c] = pd.to_numeric(df[c].astype(str).str.replace(',', ''), errors='coerce')
YRS = [2020, 2021, 2022, 2023, 2024]
print(df.shape)
In [ ]:
BIOSIM = {'Q5107':'bevacizumab','Q5118':'bevacizumab','Q5126':'bevacizumab','Q5129':'bevacizumab',
'Q5112':'trastuzumab','Q5113':'trastuzumab','Q5114':'trastuzumab','Q5116':'trastuzumab','Q5117':'trastuzumab',
'Q5115':'rituximab','Q5119':'rituximab','Q5123':'rituximab'}
REF = {'J9035':'bevacizumab','J9355':'trastuzumab','J9312':'rituximab'}
CELL = ['Q2041','Q2042','Q2043','Q2053','Q2054','Q2055','Q2056']
code_ = df.HCPCS_Cd.astype(str)
NON_ONC = ['J9332','J9333','J9334','J9381','J9210','J9209'] # gMG (Vyvgart, Rystiggo), T1D (Tzield), HLH (Gamifant), mesna (supportive)
df['oncology'] = (code_.str.match(r'^J9\d{3}$') & ~code_.isin(NON_ONC)) | code_.isin(list(BIOSIM) + CELL)
onc = df[df.oncology].copy()
tot = pd.DataFrame({'all_partb': [df[f'Tot_Spndng_{y}'].sum() for y in YRS],
'oncology': [onc[f'Tot_Spndng_{y}'].sum() for y in YRS]}, index=YRS)
tot['onc_share'] = tot.oncology / tot.all_partb
print('oncology codes:', len(onc)); (tot.assign(all_partb=tot.all_partb/1e9, oncology=tot.oncology/1e9))
Finding 1 — Size and growth of oncology spending¶
In [ ]:
g = tot.oncology[2024]/tot.oncology[2020]-1; cagr=(tot.oncology[2024]/tot.oncology[2020])**(1/4)-1
ga = tot.all_partb[2024]/tot.all_partb[2020]-1
print(f"Oncology Part B drug spend: ${tot.oncology[2020]/1e9:.1f}B (2020) -> ${tot.oncology[2024]/1e9:.1f}B (2024), +{g:.0%} (CAGR {cagr:.1%})")
print(f"All Part B drugs: +{ga:.0%}. Oncology share of 2024 Part B drug spend: {tot.onc_share[2024]:.0%}")
fig, ax = plt.subplots(figsize=(8,4.5))
ax.bar([str(y) for y in YRS], tot.oncology/1e9, color='#2b6cb0')
for i,v in enumerate(tot.oncology/1e9): ax.text(i, v+0.3, f'${v:.1f}B', ha='center')
ax.set_title('Medicare Part B oncology drug spending, 2020–2024'); ax.set_ylabel('$ billions')
ax.spines[['top','right']].set_visible(False)
fig.text(0.01,0.01,'Source: CMS Medicare Part B Spending by Drug (DY2024). Oncology = HCPCS J9xxx + oncology biosimilar & cell-therapy Q-codes.',fontsize=7)
plt.tight_layout(rect=(0,0.04,1,1)); plt.savefig('charts/01_oncology_trend.png', dpi=160); plt.show()
Finding 2 — Spending is highly concentrated¶
In [ ]:
onc['spend_2024']=onc.Tot_Spndng_2024; top = onc.sort_values('spend_2024', ascending=False).head(10)
share_top1 = top.spend_2024.iloc[0]/tot.oncology[2024]; share_top10 = top.spend_2024.sum()/tot.oncology[2024]
print(f"Top drug ({top.Brnd_Name.iloc[0]}) = {share_top1:.0%} of oncology spend; top 10 = {share_top10:.0%} of {len(onc)} codes")
fig, ax = plt.subplots(figsize=(8,5))
lab = top.Brnd_Name.str.replace('*','',regex=False) + ' (' + top.Gnrc_Name.str.replace('*','',regex=False).str.replace('Fam-','',regex=False).str.split('-').str[0] + ')'
ax.barh(lab[::-1], top.spend_2024[::-1]/1e9, color='#2b6cb0')
for i,v in enumerate(top.spend_2024[::-1]/1e9): ax.text(v+0.05, i, f'${v:.2f}B', va='center', fontsize=8)
ax.set_title(f'Top 10 oncology drugs, 2024 Medicare Part B spend
(together {share_top10:.0%} of oncology total)'); ax.set_xlabel('$ billions')
ax.spines[['top','right']].set_visible(False)
fig.text(0.01,0.01,'Source: CMS Medicare Part B Spending by Drug (DY2024).',fontsize=7)
plt.tight_layout(rect=(0,0.03,1,1)); plt.savefig('charts/02_top10_2024.png', dpi=160); plt.show()
top[['HCPCS_Cd','Brnd_Name','Gnrc_Name','Tot_Spndng_2024','Tot_Benes_2024','Avg_Spndng_Per_Bene_2024']]
Finding 3 — Is growth driven by price or volume?¶
In [ ]:
o = onc.dropna(subset=['Tot_Spndng_2020','Tot_Spndng_2024']).copy()
o['growth'] = o.Tot_Spndng_2024 - o.Tot_Spndng_2020
o['price_chg'] = o.Avg_Spndng_Per_Dsg_Unt_2024/o.Avg_Spndng_Per_Dsg_Unt_2020-1
o['unit_chg'] = o.Tot_Dsg_Unts_2024/o.Tot_Dsg_Unts_2020-1
new_codes = onc[onc.Tot_Spndng_2020.isna()].Tot_Spndng_2024.sum()
total_growth = tot.oncology[2024]-tot.oncology[2020]
gr = o.sort_values('growth', ascending=False).head(8)
print(f"Total oncology growth 2020->2024: ${total_growth/1e9:.2f}B; codes with no 2020 spend (new launches/new codes) contributed ${new_codes/1e9:.2f}B")
print(f"Top 8 growth drugs account for ${gr.growth.sum()/1e9:.2f}B")
gr[['Brnd_Name','Gnrc_Name','Tot_Spndng_2020','Tot_Spndng_2024','growth','price_chg','unit_chg']]
In [ ]:
fig, ax = plt.subplots(figsize=(8,4.8))
lab = gr.Brnd_Name.str.replace('*','',regex=False)
ax.barh(lab[::-1], gr.growth[::-1]/1e9, color='#38a169')
for i,(v,p,u) in enumerate(zip(gr.growth[::-1]/1e9, gr.price_chg[::-1], gr.unit_chg[::-1])):
ax.text(v+0.03, i, f'+${v:.2f}B (price {p:+.0%}, units {u:+.0%})', va='center', fontsize=8)
ax.set_xlim(0, gr.growth.max()/1e9*1.6)
ax.set_title('Largest $ growth in oncology Part B spend, 2020→2024'); ax.set_xlabel('$ billions added')
ax.spines[['top','right']].set_visible(False)
fig.text(0.01,0.01,'Source: CMS Medicare Part B Spending by Drug (DY2024). Price = avg spend per dosage unit.',fontsize=7)
plt.tight_layout(rect=(0,0.03,1,1)); plt.savefig('charts/03_growth_drivers.png', dpi=160); plt.show()
Finding 4 — Biosimilar adoption and the price gap¶
In [ ]:
rows=[]
for code_id, mol in {**REF, **BIOSIM}.items():
r = df[df.HCPCS_Cd==code_id].iloc[0]
for y in YRS:
rows.append(dict(molecule=mol, hcpcs=code_id, brand=r.Brnd_Name, type='Reference' if code_id in REF else 'Biosimilar',
year=y, spend=r[f'Tot_Spndng_{y}'], units=r[f'Tot_Dsg_Unts_{y}']))
bs = pd.DataFrame(rows).fillna(0)
agg = bs.groupby(['molecule','year','type'])[['spend','units']].sum().unstack('type')
share = (agg['spend']['Biosimilar']/agg['spend'].sum(axis=1)).unstack('year')
ppu = (agg['spend']/agg['units']).xs(2024, level='year')
ppu['biosim_discount'] = 1-ppu.Biosimilar/ppu.Reference
# illustrative: savings if remaining 2024 reference units were billed at the biosimilar avg per-unit spend
ref24 = agg.xs(2024, level='year')
ppu['ref_units_2024']=ref24['units']['Reference']; ppu['illustrative_savings']=ppu.ref_units_2024*(ppu.Reference-ppu.Biosimilar)
print('Biosimilar share of molecule spend:'); display(share)
display(ppu)
In [ ]:
fig, ax = plt.subplots(figsize=(8,4.5))
for mol,c in zip(share.index, ['#2b6cb0','#d69e2e','#c53030']):
ax.plot(YRS, share.loc[mol]*100, marker='o', label=f'{mol} (2024 per-unit discount {ppu.loc[mol,"biosim_discount"]:.0%})', color=c)
ax.set_ylim(0,100); ax.set_ylabel('% of molecule spend on biosimilars'); ax.set_xticks(YRS)
ax.set_title('Biosimilar share of Medicare Part B spend, by molecule'); ax.legend(frameon=False, fontsize=8)
ax.spines[['top','right']].set_visible(False)
fig.text(0.01,0.01,'Source: CMS Medicare Part B Spending by Drug (DY2024). Reference vs. biosimilar HCPCS codes, same 10 mg unit.',fontsize=7)
plt.tight_layout(rect=(0,0.04,1,1)); plt.savefig('charts/04_biosimilar_share.png', dpi=160); plt.show()
Tableau extract¶
In [ ]:
long=[]
for _,r in onc.iterrows():
for y in YRS:
long.append(dict(hcpcs=r.HCPCS_Cd, brand=str(r.Brnd_Name).replace('*',''), generic=str(r.Gnrc_Name).replace('*',''),
biosimilar_molecule=BIOSIM.get(r.HCPCS_Cd, REF.get(r.HCPCS_Cd,'')), product_type='Biosimilar' if r.HCPCS_Cd in BIOSIM else ('Reference' if r.HCPCS_Cd in REF else 'Other'),
year=y, total_spending=r[f'Tot_Spndng_{y}'], dosage_units=r[f'Tot_Dsg_Unts_{y}'], claims=r[f'Tot_Clms_{y}'],
beneficiaries=r[f'Tot_Benes_{y}'], spend_per_unit=r[f'Avg_Spndng_Per_Dsg_Unt_{y}'], spend_per_bene=r[f'Avg_Spndng_Per_Bene_{y}']))
ext = pd.DataFrame(long).dropna(subset=['total_spending'])
ext.to_csv('tableau_oncology_partb_2020_2024.csv', index=False); print(ext.shape)
5. Strategy implications for an oncology specialty benefit manager¶
- Focus where the dollars are. A few immunotherapies (PD-1/PD-L1) and myeloma antibodies make up most of the spend. Pathway adherence, dosing (for example, weight-based vs. flat dosing and the right interval), and duration-of-therapy reviews on those few drugs would affect the most dollars.
- Growth comes from both price and use, and new codes matter most. For most of the top growth drugs (Keytruda, Padcev, Enhertu, Imfinzi, Libtayo, Polivy), units grew faster than per-unit price, which reflects new indications and more uptake. Opdivo's growth was almost all price. Programs on appropriate use and pathways matter as much as price.
- Biosimilars are a proven lever, but the remaining headroom is uneven. Bevacizumab and trastuzumab have moved heavily to biosimilars. Rituximab has room to keep shifting. The per-unit gaps above show what each remaining reference unit costs.
- Watch new launches. Codes with no 2020 spending (new drugs, new codes, and new formulations like subcutaneous versions) add a large share of the growth. Horizon scanning matters.
6. Limitations¶
- This covers Medicare Part B fee-for-service only. It leaves out Medicare Advantage, commercial, and Part D oral oncolytics.
- Spending is before rebates and includes beneficiary cost sharing. The data has no site of care (office vs. hospital outpatient), so site-of-care savings can't be measured here.
- Oncology scope is set by HCPCS code. A few J9 codes or biosimilars, such as rituximab, are also used outside cancer. CMS can't tell indication from this file.
- The "illustrative savings" figure is a simple what-if (remaining reference units × per-unit gap). It isn't a forecast, and it ignores clinical, contracting, and switching constraints.
- Some codes are suppressed or new in some years, so year-over-year comparisons by code are limited to codes present in both years.