Files
stack/dev/scripts/generate_skin_sub_claims.py
kert a7ecf9a758
Some checks failed
CI / skinny-install (aco) (push) Successful in 48s
CI / lint-test (push) Successful in 1m19s
CI / skinny-install (api) (push) Successful in 32s
CI / skinny-install (bib) (push) Successful in 28s
CI / skinny-install (bcda) (push) Successful in 35s
CI / skinny-install (bls) (push) Successful in 29s
CI / skinny-install (ccw) (push) Successful in 31s
CI / skinny-install (cms) (push) Failing after 16m51s
CI / skinny-install (conf) (push) Failing after 0s
CI / skinny-install (perf) (push) Failing after 0s
CI / skinny-install (pfs) (push) Failing after 0s
CI / skinny-install (rex) (push) Failing after 0s
Deploy / build-scan-report (push) Failing after 0s
CI / skinny-install (cli) (push) Failing after 27m40s
data: synthetic skin sub claims — 2,000 encounters, product + application codes
CMS synthetic data has zero skin substitute claims. This generator
creates realistic encounters based on documented utilization patterns.

Each encounter produces 2-3 claim lines:
  Line 1: Q4xxx product code (units = cm² applied)
  Line 2: 15271/15275 initial application (1 unit)
  Line 3: 15272/15276 add-on (if wound >25cm²)

Distributions sourced from:
- OIG Sept 2025 report on skin substitute payment trends
- CMS CY 2026 OPPS Final Rule (CMS-1834-FC)
- DOJ enforcement actions (Jenson, Gehrke/King, Vohra)

Provider volume tiers (85%/10%/5%) enable predatory pattern testing.
Every row tagged with source=synthetic and source_methodology.

Result: 4,257 claim lines in skin_subs.claims_synthetic

Refs #234
2026-03-25 10:09:20 -04:00

377 lines
13 KiB
Python

"""Generate synthetic skin substitute claims for development and testing.
The CMS synthetic data (SynPUF-derived) does not contain skin substitute
claims. This script generates realistic synthetic claims based on known
utilization patterns from OIG reports and CMS data.
Methodology
-----------
Each synthetic skin substitute encounter generates 2-3 claim lines:
Line 1: Q4xxx product code — units = cm² of product applied
Line 2: 15271 or 15275 (initial application, first 25cm²) — 1 unit
Line 3: 15272 or 15276 (add-on, each additional 25cm²) — if >25cm²
Encounter parameters drawn from distributions based on:
- OIG Sept 2025 report: "Medicare Part B Payment Trends for Skin Substitutes
Raise Major Concerns About Fraud, Waste, and Abuse"
(https://oig.hhs.gov/reports/all/2025/medicare-part-b-payment-trends-for-
skin-substitutes-raise-major-concerns-about-fraud-waste-and-abuse/)
- CMS CY 2026 OPPS Final Rule (CMS-1834-FC): spending breakdown by setting,
volume-weighted ASP data
- DOJ enforcement actions: Jenson (S.D. Tex. 4:25-cr-00271),
Gehrke/King (D. Ariz.), Vohra (S.D. Fla.)
Provider distribution
---------------------
- 60% podiatry (DPM) — per OIG, podiatrists account for majority of
non-institutional skin sub claims
- 15% dermatology
- 10% general surgery / wound care
- 10% nurse practitioner (NP) / physician assistant (PA)
- 5% other
Setting distribution (non-institutional, per OIG)
--------------------------------------------------
- 70% office (POS 11)
- 15% HOPD (POS 22)
- 10% ASC (POS 24)
- 5% SNF (POS 31)
Product distribution
--------------------
- Dominated by amniotic membrane products (75% of codes, ~80% of volume)
- Top products by volume: Q4186 (EpiFix), Q4132 (Grafix Core), Q4101 (Apligraf)
Geographic distribution
-----------------------
- Concentrated in Sun Belt states (FL, TX, CA, AZ, NV) per OIG
- MAC jurisdiction variation in LCD permissiveness
Usage:
uv run python dev/scripts/generate_skin_sub_claims.py
uv run python dev/scripts/generate_skin_sub_claims.py --encounters 5000
"""
from __future__ import annotations
import argparse
import random
from datetime import date, timedelta
from pathlib import Path
import duckdb
import polars as pl
ROOT = Path(__file__).resolve().parents[2]
DUCKDB_PATH = ROOT / "data" / "aco.duckdb"
# Source: OIG Sept 2025, CMS CY 2026 OPPS Final Rule
SPECIALTIES = [
("podiatry", 0.60),
("dermatology", 0.15),
("general_surgery", 0.10),
("nurse_practitioner", 0.10),
("other", 0.05),
]
SETTINGS = [
(11, "office", 0.70),
(22, "hopd", 0.15),
(24, "asc", 0.10),
(31, "snf", 0.05),
]
# Top products by utilization volume (approximate, from ASP volume data)
# Q4186=EpiFix, Q4132=Grafix, Q4101=Apligraf, Q4121=TheraSkin, Q4195=PuraPly
TOP_PRODUCTS = [
("Q4186", "EPIFIX", 0.20),
("Q4132", "GRAFIX CORE", 0.12),
("Q4101", "APLIGRAF", 0.10),
("Q4121", "THERASKIN", 0.08),
("Q4195", "PURAPLY AM", 0.07),
("Q4122", "DERMACELL", 0.06),
("Q4133", "GRAFIX PRIME", 0.05),
("Q4158", "KERECIS OMEGA3", 0.05),
("Q4196", "PURAPLY XT", 0.04),
("Q4100", "SKIN SUB NOS", 0.23), # catch-all for other products
]
# Diagnosis codes for chronic wounds (ICD-10-CM)
# L97.x = non-pressure chronic ulcer of lower extremity
# L89.x = pressure ulcer
DIAGNOSES = [
(
"L97.519",
"Non-pressure chronic ulcer of other part of unspecified foot with unspecified severity",
),
(
"L97.529",
"Non-pressure chronic ulcer of other part of left foot with unspecified severity",
),
(
"L97.419",
"Non-pressure chronic ulcer of unspecified heel and midfoot with unspecified severity",
),
(
"L97.919",
"Non-pressure chronic ulcer of unspecified lower leg with unspecified severity",
),
("L89.159", "Pressure ulcer of sacral region, unspecified stage"),
("E11.621", "Type 2 diabetes mellitus with foot ulcer"),
(
"I83.019",
"Varicose veins of unspecified lower extremity with ulcer of unspecified site",
),
]
# States weighted by Medicare skin sub utilization (Sun Belt heavy)
# Source: OIG geographic analysis
STATES = [
("FL", 0.18),
("TX", 0.14),
("CA", 0.12),
("AZ", 0.06),
("NV", 0.04),
("GA", 0.05),
("NC", 0.04),
("OH", 0.04),
("PA", 0.04),
("IL", 0.04),
("NY", 0.03),
("MI", 0.03),
("TN", 0.03),
("VA", 0.03),
("LA", 0.03),
("OTHER", 0.10),
]
def _weighted_choice(options: list[tuple]) -> tuple:
"""Pick from weighted options. Last element of each tuple is the weight."""
values = [o[:-1] if len(o) > 2 else (o[0],) for o in options]
weights = [o[-1] for o in options]
return random.choices(values, weights=weights, k=1)[0]
def generate_encounters(n: int = 2000, seed: int = 42) -> pl.DataFrame:
"""Generate n synthetic skin substitute encounters as claim lines."""
random.seed(seed)
records = []
base_date = date(2022, 1, 1)
date_range_days = 365 * 3 # 3 years of claims
# Generate ~200 unique providers (concentrated — some high-volume)
n_providers = min(200, n // 5)
providers = []
for i in range(n_providers):
specialty = _weighted_choice(SPECIALTIES)[0]
state = _weighted_choice(STATES)[0]
npi = f"{1000000000 + i}"
# Some providers are high-volume (predatory pattern)
volume_tier = random.choices(
["normal", "high", "extreme"], weights=[0.85, 0.10, 0.05]
)[0]
providers.append((npi, specialty, state, volume_tier))
# Generate ~1000 unique patients
n_patients = min(1000, n)
patients = []
for i in range(n_patients):
pid = f"SYNTH_{i:06d}"
age = random.randint(55, 95)
gender = random.choice(["M", "F"])
state = _weighted_choice(STATES)[0]
patients.append((pid, age, gender, state))
claim_id = 100000
for _ in range(n):
claim_id += 1
# Pick provider (weighted toward high-volume)
prov = random.choice(providers)
npi, specialty, prov_state, volume_tier = prov
# Pick patient (preferably from same state)
same_state = [p for p in patients if p[3] == prov_state]
if same_state and random.random() < 0.7:
patient = random.choice(same_state)
else:
patient = random.choice(patients)
pid, age, gender, pat_state = patient
# Service date
svc_date = base_date + timedelta(days=random.randint(0, date_range_days))
# Setting
pos_code, pos_name = _weighted_choice(SETTINGS)
# Product
product_code, product_name = _weighted_choice(TOP_PRODUCTS)
# Wound size (cm²) — determines units and whether add-on code needed
# Normal: 5-25cm², High-volume: 10-50cm², Extreme: 20-100cm²
if volume_tier == "extreme":
wound_cm2 = random.randint(20, 100)
elif volume_tier == "high":
wound_cm2 = random.randint(10, 50)
else:
wound_cm2 = random.randint(5, 25)
# Diagnosis
dx_code, dx_desc = random.choice(DIAGNOSES)
# Anatomy determines application code family
# 15271-15274: trunk, arms, legs (80% of wounds)
# 15275-15278: face, scalp, hands, feet (20%)
if random.random() < 0.80:
app_base = "15271"
app_addon = "15272"
else:
app_base = "15275"
app_addon = "15276"
# --- Line 1: Product code ---
records.append(
{
"claim_id": f"CLM{claim_id}",
"claim_line_number": 1,
"person_id": pid,
"service_date": str(svc_date),
"hcpcs_code": product_code,
"short_description": product_name,
"units": wound_cm2,
"paid_amount": round(
wound_cm2 * random.uniform(20, 80), 2
), # varies by product
"rendering_npi": npi,
"provider_specialty": specialty,
"place_of_service": pos_code,
"place_of_service_description": pos_name,
"diagnosis_code_1": dx_code,
"state": prov_state,
"patient_age": age,
"patient_gender": gender,
"claim_type": "product",
"source": "synthetic",
"source_methodology": "generate_skin_sub_claims.py — distributions from OIG Sept 2025, CMS CY 2026 OPPS Final Rule",
}
)
# --- Line 2: Application code (initial) ---
records.append(
{
"claim_id": f"CLM{claim_id}",
"claim_line_number": 2,
"person_id": pid,
"service_date": str(svc_date),
"hcpcs_code": app_base,
"short_description": f"Skin sub graft initial {'trunk/arm/leg' if app_base == '15271' else 'face/neck/hf/g'}",
"units": 1,
"paid_amount": round(random.uniform(100, 250), 2),
"rendering_npi": npi,
"provider_specialty": specialty,
"place_of_service": pos_code,
"place_of_service_description": pos_name,
"diagnosis_code_1": dx_code,
"state": prov_state,
"patient_age": age,
"patient_gender": gender,
"claim_type": "application",
"source": "synthetic",
"source_methodology": "generate_skin_sub_claims.py — distributions from OIG Sept 2025, CMS CY 2026 OPPS Final Rule",
}
)
# --- Line 3: Add-on application (if >25cm²) ---
if wound_cm2 > 25:
addon_units = (wound_cm2 - 25 + 24) // 25 # each additional 25cm²
records.append(
{
"claim_id": f"CLM{claim_id}",
"claim_line_number": 3,
"person_id": pid,
"service_date": str(svc_date),
"hcpcs_code": app_addon,
"short_description": f"Skin sub graft add-on {'trunk/arm/leg' if app_addon == '15272' else 'face/neck/hf/g'}",
"units": addon_units,
"paid_amount": round(addon_units * random.uniform(30, 80), 2),
"rendering_npi": npi,
"provider_specialty": specialty,
"place_of_service": pos_code,
"place_of_service_description": pos_name,
"diagnosis_code_1": dx_code,
"state": prov_state,
"patient_age": age,
"patient_gender": gender,
"claim_type": "application_addon",
"source": "synthetic",
"source_methodology": "generate_skin_sub_claims.py — distributions from OIG Sept 2025, CMS CY 2026 OPPS Final Rule",
}
)
return pl.DataFrame(records)
def main() -> None:
parser = argparse.ArgumentParser(description="Generate synthetic skin sub claims")
parser.add_argument(
"--encounters", type=int, default=2000, help="Number of encounters"
)
parser.add_argument("--seed", type=int, default=42, help="Random seed")
args = parser.parse_args()
print(f"Generating {args.encounters} synthetic skin substitute encounters...")
df = generate_encounters(n=args.encounters, seed=args.seed)
print(f" Total claim lines: {len(df)}")
print(f" Product lines: {df.filter(pl.col('claim_type') == 'product').height}")
print(
f" Application lines: {df.filter(pl.col('claim_type') == 'application').height}"
)
print(
f" Add-on lines: {df.filter(pl.col('claim_type') == 'application_addon').height}"
)
# Save to CSV
out_csv = ROOT / "data" / "cms" / "skin_subs_synthetic_claims.csv"
df.write_csv(out_csv)
print(f"\nWrote {len(df)} rows to {out_csv}")
# Load into DuckDB
print(f"\nLoading into DuckDB at {DUCKDB_PATH}...")
con = duckdb.connect(str(DUCKDB_PATH))
con.execute("CREATE SCHEMA IF NOT EXISTS skin_subs")
con.execute("DROP TABLE IF EXISTS skin_subs.claims_synthetic")
con.execute(
"CREATE TABLE skin_subs.claims_synthetic AS SELECT * FROM read_csv_auto(?, header=true)",
[str(out_csv)],
)
count = con.execute("SELECT count(*) FROM skin_subs.claims_synthetic").fetchone()[0]
# Summary stats
print(f" Loaded {count} rows into skin_subs.claims_synthetic")
stats = con.execute("""
SELECT
count(DISTINCT claim_id) as encounters,
count(DISTINCT person_id) as patients,
count(DISTINCT rendering_npi) as providers,
count(DISTINCT hcpcs_code) as codes,
sum(paid_amount::DOUBLE) as total_paid,
min(service_date) as first_date,
max(service_date) as last_date
FROM skin_subs.claims_synthetic
""").fetchone()
print(f" Encounters: {stats[0]:,}")
print(f" Patients: {stats[1]:,}")
print(f" Providers: {stats[2]:,}")
print(f" Codes used: {stats[3]}")
print(f" Total paid: ${stats[4]:,.2f}")
print(f" Date range: {stats[5]} to {stats[6]}")
con.close()
if __name__ == "__main__":
main()