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
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
377 lines
13 KiB
Python
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()
|