I am a CPA and a Certified Fraud Examiner, and most of my week is due diligence. This piece is about a weekend question I finally sat down to answer: how much can one practitioner actually learn about a state’s healthcare market using only public data and a laptop? The honest answer surprised me. The data is good enough now that the limiting factor is no longer access. It is discipline.
This is the first entry in DD Tech Lab, where I show the actual code behind the diligence. It is written for two readers: the curious beginner who has never opened a data file larger than a spreadsheet, and the intermediate analyst who wants the joins and the ranking logic. The worked example is a Nevada home-care screen. Its most useful feature is not a scandal. It is the absence of one. Every loud signal I found got quieter when I tested it. That restraint is the whole point. The ACFE teaches professional skepticism as a two-way discipline: you withhold belief from the innocent story and the guilty one until the evidence converges. A screen that only escalates is not analysis. It is a story generator.
Everything below runs on two free Python packages and public files. No credentials, no vendor platform.
pip install requests duckdb
Why a fraud examiner starts with public healthcare data
Government healthcare programs are, structurally, the disbursement environment the ACFE Fraud Tree was built to map: provider schemes live mostly on the corruption and asset-misappropriation branches (billing for services not rendered, upcoding, disbursements through controlled entities). Two public matters frame the typology a screen should be tuned to find.
| Benchmark | Figure | Source |
|---|---|---|
| Minnesota “Feeding Our Future” | ~\$250M charged (largest pandemic-relief fraud) | U.S. DOJ |
| Arizona AHCCCS behavioral-health / “sober home” scheme | up to ~\$2.5B suspect billing (state estimate) | AZ Attorney General |
| Occupational fraud, all sectors | ~5% of revenue lost annually; ~12-month median duration; tips detect more than analytics | ACFE Report to the Nations |
The shared signature across the program-fraud cases is not size. It is new entities scaling from near-zero, fast, with thin operational substance. Hold that thought. It is the thing the data can see, and the thing a careless analyst will confuse with ordinary business growth.
Step 1 (beginner): who are the providers?
Every U.S. healthcare provider has a National Provider Identifier. The NPPES registry publishes them through a free, keyless API. Here is a real call that returns home-care organizations in Las Vegas:
import requests
resp = requests.get("https://npiregistry.cms.hhs.gov/api/", params={
"version": "2.1",
"state": "NV",
"city": "Las Vegas",
"taxonomy_description": "Home Health",
"limit": 5,
})
for p in resp.json()["results"]:
basic = p["basic"]
name = basic.get("organization_name") or f'{basic.get("first_name","")} {basic.get("last_name","")}'
print(p["number"], p["enumeration_type"], name)
Two fields matter more than the rest. enumeration_type separates NPI-1 (an individual: a nurse, an aide, a therapist) from NPI-2 (an organization: an LLC or a corporation). And for organizations, authorized_official names the person who signed for the entity. Those two fields carry most of the structural signal, because the question we care about is how many organizations does one person stand behind, and where.
The API caps a single query at 1,200 records, so a real pull loops over cities and taxonomies and de-duplicates by NPI into a cache. Keep every record; let later steps decide what matters. This layer is deliberately overinclusive: it produces leads, not findings.
Step 2 (beginner to intermediate): the billing spine
Identity tells you who exists. Billing tells you who is paid. CMS now publishes a provider-level Medicaid spending file, about 238 million rows for 2018 through 2024, seven columns: billing NPI, servicing NPI, HCPCS code, claim month, total patients, total claim lines, total paid (the KFF explainer covers what it does and does not contain).
It ships as a ~3GB Parquet file. A beginner’s instinct is to open it in a spreadsheet; do not. Use DuckDB, which queries the file in place without loading it into memory:
import duckdb
con = duckdb.connect()
yearly = con.execute("""
SELECT LEFT(CAST(CLAIM_FROM_MONTH AS VARCHAR), 4) AS year,
ROUND(SUM(TOTAL_PAID), 0) AS paid,
COUNT(DISTINCT BILLING_PROVIDER_NPI_NUM) AS billers
FROM 'cms.parquet'
WHERE HCPCS_CODE = 'T1019' -- personal care, per 15 min
GROUP BY 1 ORDER BY 1
""").fetchall()
for year, paid, billers in yearly:
print(year, f"${paid:,.0f}", billers)
That is the whole intermediate leap: SQL GROUP BY over a quarter-billion rows on a laptop, in seconds. If you can write that query, you can run a market screen.
Step 3 (intermediate): cluster by address and controller
Now join the two worlds. Pull your state’s organization NPIs from NPPES, then ask where they pile up. Counting organizations per address (not individuals) is the signal: a roster of 500 individual aides at one suite is an employer, not 500 shells.
from collections import defaultdict
clusters = defaultdict(lambda: {"orgs": set(), "officials": set()})
for rec in nppes_cache: # the de-duped cache from Step 1
if rec["enumeration_type"] != "NPI-2":
continue
addr = normalize_address(rec) # uppercase, collapse "STE/SUITE/#"
clusters[addr]["orgs"].add(rec["number"])
clusters[addr]["officials"].add(authorized_official(rec))
# The discriminator that kills most false positives:
for addr, c in clusters.items():
if len(c["orgs"]) < 2:
continue
ratio = len(c["officials"]) / len(c["orgs"])
label = "SHARED_OFFICE_OR_AGENT" if ratio >= 0.75 else \
"SINGLE_CONTROLLER" if ratio <= 0.40 else "MIXED"
print(len(c["orgs"]), f"{ratio:.2f}", label, addr)
The officials-to-organizations ratio is the single most useful line of code in the whole screen. Many entities with many different officials is usually a registered-agent office, a virtual mailbox, or an executive suite, all benign address sharing. Many entities with one official is the lead worth keeping.
The companion view groups the organizations by authorized_official instead of by address, surfacing one person behind many entities across many buildings:
controllers = defaultdict(lambda: {"orgs": set(), "names": set(), "buildings": set()})
for rec in nppes_cache:
if rec["enumeration_type"] != "NPI-2":
continue
person = authorized_official(rec)
controllers[person]["orgs"].add(rec["number"])
controllers[person]["names"].add(organization_name(rec))
controllers[person]["buildings"].add(street_only(rec)) # address minus the suite
ranked = sorted(controllers.items(), key=lambda kv: len(kv[1]["orgs"]), reverse=True)
for person, c in ranked[:10]:
print(len(c["orgs"]), "orgs |", len(c["names"]), "names |",
len(c["buildings"]), "buildings |", person)
Read two numbers together here. A controller standing behind many organization NPIs is a lead. But when the org count far exceeds the count of distinct business names, much of that is re-enumeration, several NPIs for the same brand, which is a different and usually milder thing than many unrelated companies. The building count tells you whether it is a single-site shop or a genuine multi-location operator. None of this is a finding yet; it is a ranked list of questions for the billing layer.
Step 4 (intermediate): the growth-off-zero screen
This is where most leads change character. Join your provider cache to billing and ask the fraud-relevant question, not “who is large?” but “who is newly billing, growing fast from near-zero?”
con.execute("CREATE TEMP TABLE nv_orgs(npi VARCHAR)")
con.executemany("INSERT INTO nv_orgs VALUES (?)",
[(n,) for n in nv_org_npis]) # your state's NPI-2 set
leads = con.execute("""
SELECT BILLING_PROVIDER_NPI_NUM AS npi,
SUM(TOTAL_PAID) AS paid,
MIN(CLAIM_FROM_MONTH) AS first_month
FROM 'cms.parquet'
WHERE BILLING_PROVIDER_NPI_NUM IN (SELECT npi FROM nv_orgs)
GROUP BY 1
HAVING MIN(CLAIM_FROM_MONTH) >= '2023-01' -- new biller
AND SUM(TOTAL_PAID) > 300000 -- material scale
ORDER BY paid DESC
""").fetchall()
The inverse test matters just as much. When my loudest address cluster hit this query, it came back flat, large billing spread evenly across the full 2018 through 2024 window. That is a mature operator, not a new-entity ramp. Size alone is not a fraud signature. The screen quietly cleared its own top lead, which is exactly what a screen is supposed to be able to do.
Step 5 (intermediate): cross-check the exclusion list
The HHS-OIG List of Excluded Individuals and Entities is a free CSV (~83,000 rows). Load it and join on both the billing and the servicing NPI, then flag anything billed after an exclusion date:
con.execute("""
CREATE TEMP TABLE leie AS
SELECT NPI, EXCLDATE
FROM read_csv_auto('leie.csv')
WHERE NPI <> '0000000000'
""")
hits = con.execute("""
SELECT c.SERVICING_PROVIDER_NPI_NUM, l.EXCLDATE,
SUM(c.TOTAL_PAID) AS paid_after_exclusion
FROM 'cms.parquet' c
JOIN leie l ON c.SERVICING_PROVIDER_NPI_NUM = l.NPI
WHERE c.CLAIM_FROM_MONTH >= STRFTIME(STRPTIME(l.EXCLDATE,'%Y%m%d'),'%Y-%m')
GROUP BY 1, 2
""").fetchall()
In my run, the substantive excluded provider had stopped billing before its exclusion took effect, the enforcement sequence working as intended, not a violation. Which brings us to the part that matters more than any query.
Reading the output: what survived, and what didn’t
A practitioner does not read these ranked tables hunting for a smoking gun. You read them asking which leads survive contact with the next layer. On the Nevada home-care run, the honest answer was: almost none.
- The loudest raw address cluster was a roster of individual caregivers at one suite, an employer’s workforce, not a shell farm. The NPI-1/NPI-2 split removed it before it ever reached billing.
- The top organization cluster sat under a single official, a real single-controller lead. But its entity count far exceeded its distinct business names, so most were re-enumerations of a few brands. A different shape than the textbook “many unrelated LLCs at one building.”
- The top controller stood behind dozens of organization NPIs across many buildings. On the billing layer, those entities billed steadily across the entire 2018 through 2024 window. That is a mature operator, not a near-zero ramp. The growth screen cleared its own loudest lead.
- The growth screen’s headline new biller showed a dramatic per-patient figure. Two corrections deflated it: the denominator was patient-months, and the service mix was skilled nursing, simply a higher-dollar category than personal care. The lead did not vanish, but the “rate outrage” was not a publishable claim.
Each of those is a clear-down. The output of a good screen is mostly a list of leads you can responsibly set aside, plus a short queue of items that still merit a records request. If your screen never produces clear-downs, it is miscalibrated: you have built a machine that confirms whatever you went looking for.
How not to cry fraud
This checklist is the real deliverable. Every rule is a trap I personally walked into and back out of on this dataset.
- Split individuals from organizations. A big caregiver roster at one address is a workforce, not a shell farm.
- Filter mail drops and registered-agent addresses with the officials-to-organization ratio before you believe any cluster.
- Know your denominator. In this file, “total patients” is patient-months, not unique patients. Treat it as unique beneficiaries and you will manufacture per-patient outliers that are not real.
- Growth off zero is the signal. A large, flat, seven-year biller is a mature operator. Size is not a signature.
- Service mix explains outliers. Skilled nursing and personal care are different cost categories; compare on HCPCS, not a broad label.
- Name search matches strings, not identities. A registry name hit is a lead. Common names are dangerous. Resolve identity through addresses and agents before you call anything a “network.”
- Exclusion lists lag. A clean check does not clear conduct; a provider that stopped billing before a later exclusion is not an active violation.
- Default to “cleared,” not “accused.” Escalation requires convergence: structural clustering, anomalous billing, ownership linkage, and timing that make sense together. Absent convergence, the disciplined output is a documented null.
That last rule is the professional-skepticism standard the ACFE keeps restating. Skepticism is not the presumption of guilt. It is the refusal to accept either story without evidence, and the willingness to write “this cleared” with the same rigor you would bring to “this did not.”
What this screen cannot see
The public file has no claim units, so a dollar-per-line figure is not a billing rate. It carries no diagnoses, medical necessity, modifiers, place of service, or beneficiary identity. It cannot prove a service happened, that a patient qualified, that a worker was present, or that a kickback existed. It cannot see ghost patients, and it cannot pierce a nominee owner the public filings do not reveal.
Those questions need records requests, claim-level data, enrollment files, licensing and court records, interviews, and sometimes subpoena power. The weekend screen does one thing well: it tells a diligence team where not to spend its hours, and where a records request might be justified. It is a triage instrument, not a verdict, which is why the discipline to clear a lead matters as much as the skill to find one. That is the work we will keep building in DD Tech Lab.
By Noah Green, CPA, CFE, for Sheepdog Prosperity Partners. This walkthrough describes a public-records research method; it makes no allegation against any person or entity, and all operator-level specifics from the underlying screen have been generalized. Code is illustrative and provided as-is.
Primary sources & tools: NPPES NPI Registry · CMS Medicaid provider-level data · HHS-OIG Exclusions (LEIE) · DuckDB · ACFE Report to the Nations · ACFE Fraud Tree · DOJ: Feeding Our Future · KFF: Medicaid data
