← Back to the Pathway·Applied trace · From Data to BedsideGlossary →

Outlier Detection in Medicaid Provider Spending

Peer-group robust z-scores, isolation forests, and a geographic lens on provider-level Medicaid claims

Author

Paulina Del Mundo Del Fierro, MD, MPH

Published

June 18, 2026

TipAbstract

Medicaid is the largest source of health coverage for low-income Americans, and the federal government releases monthly snapshots of who got paid for what. The interesting question isn’t “who bills the most” (huge hospital systems dominate by default) but “who bills atypically compared to other providers doing the same thing?” That signal is what flags candidates for fraud review or atypical practice patterns worth a second look. This notebook works through 11 GB of provider-level billing data on a laptop using a small streaming-database trick, then computes, for every provider on every billing code, how far their per-patient spending sits from the typical spending in their peer group.

Two key findings come out. First, the spending data is extremely lopsided — payments per patient span four orders of magnitude — so the textbook “z-score” method fails completely. A robustness fix that’s resistant to extreme values (the median absolute deviation) works instead. Second, when you apply that to thousands of providers, raw cutoffs produce huge numbers of false alarms; pairing the robust z-score with a false-discovery-rate correction keeps the alert list to providers actually worth investigating. The notebook also builds a county-level interactive map of where Medicaid dollars flow by category of care. Two caveats are load-bearing: the peer group as currently defined is too broad (“everyone billing this code” mixes specialties and practice sizes), and there’s no comparison against a list of known excluded providers — so the output is best read as “exploratory surfacing for human review,” not validated fraud detection.

1 Why this analysis, and what you’ll learn from it

Medicaid is the largest source of health insurance for low-income Americans, and the federal Department of Health and Human Services releases a public file every month listing how much each healthcare provider was paid for each procedure or product they billed. That file is around 11 gigabytes of comma-separated text — far too large to open in Excel, but well within reach of a laptop if you process it the right way.

The question we want to ask is not “who got paid the most” — that’s always going to be huge hospital systems and dialysis chains by sheer size. The interesting question is “who got paid atypically, given what their peers were paid for the same procedure?” That’s the signal that fraud-and-abuse review teams, network-integrity auditors, and health-services researchers all want to surface from this kind of data.

By the end of this notebook, you should understand:

  1. How to process a dataset much larger than your computer’s memory — the streaming-database approach in prep.py reads 11 GB of text without ever loading it all at once.
  2. Why the textbook z-score fails on real-world spending data, and how the robust z-score (built on median and median absolute deviation instead of mean and standard deviation) fixes it.
  3. What the false discovery rate (FDR) is, and why ranking provider rows by an FDR-adjusted q-value is honest about the fact that you’re running thousands of statistical tests at once.
  4. How an isolation forest — an unsupervised, tree-based anomaly detector — produces a second opinion on the same data using the joint distribution of multiple features, not just one variable at a time.
  5. How to draw inequality conclusions about geographic spending patterns using the Gini coefficient and the Lorenz curve.
  6. What real research-grade caveats look like when you can only see “who billed what” but not “what diagnosis the patient had” or “whether the provider was later sanctioned.”
NoteA note on terminology — the acronyms used in this notebook

Healthcare claims data carries a lot of jargon. Here are the terms we’ll use repeatedly:

Term What it means
HCPCS Healthcare Common Procedure Coding System — a code system for billable procedures, drugs, services, and equipment. CPT codes are a subset of HCPCS.
NPI National Provider Identifier — the unique 10-digit number every US healthcare provider has.
NPPES National Plan & Provider Enumeration System — the CMS database that links every NPI to a provider’s address, specialty, and credentials.
NUCC taxonomy A code system maintained by the National Uniform Claim Committee that maps providers to specialties (e.g., “207RC0000X” = Cardiology).
ZCTA ZIP Code Tabulation Area — the Census Bureau’s polygon approximations of ZIP code boundaries. ZIPs themselves are postal-route definitions, not geographic shapes.
FIPS Federal Information Processing Standard — codes for US states (2 digits) and counties (5 digits).
MAD Median Absolute Deviation — for a set of numbers, take each value’s distance from the median, then take the median of those distances. A robust analog of standard deviation.
Robust z-score \((x - \text{median}) / (1.4826 \times \text{MAD})\). Reads on the same scale as a standard z-score under normality, but doesn’t blow up when a few extreme values are present.
BH-FDR Benjamini–Hochberg False Discovery Rate — a procedure for adjusting p-values when you’re running many tests at once, so that no more than a fixed fraction of your “significant” flags are false alarms in expectation.
Isolation forest An unsupervised anomaly-detection algorithm that builds many random binary trees and scores points by how easily they get isolated (anomalies are isolated quickly).
Gini coefficient A 0-to-1 number measuring how unequally a quantity is distributed across units. 0 = perfect equality, 1 = one unit has everything.
Lorenz curve The graphical companion to Gini: plots the cumulative share held by the bottom X% of units.
DuckDB An in-process analytical database — like SQLite but optimised for analytics queries over large datasets. Reads CSVs and Parquets directly without loading them into memory.
Parquet A column-oriented binary file format, much smaller and faster to scan than CSV for analytic queries.

Concretely, the work has three phases:

  1. Phase 1 — prep step. Stream the 11 GB Medicaid Provider Spending CSV through DuckDB, aggregate to one row per (provider, billing code), join in NPPES (for specialty and state) and the Census ZCTA file (for geographic coordinates), then save the result as committed Parquet files. This phase is automated by prep.py; the notebook itself reads only the smaller, processed Parquets.
  2. Phase 2 — outlier surfacing. For every (provider, billing code) row, compute the robust z-score relative to peer providers billing the same code. Rank the providers by FDR-adjusted q-value. Cross-check the ranking against an isolation forest fit on a joint feature space.
  3. Phase 3 — geographic atlas. Bucket every billing code into one of 12 spending categories using a rule-based classifier, then map county-level paid-per-beneficiary as a 12-panel interactive choropleth. Pair the maps with Gini concentration numbers and state-by-state hotspot tables.
NoteData sources

The raw files stay local on disk (11 GB Medicaid + 45 GB NPPES, far too large to commit). The prep.py script runs one DuckDB pipeline that produces seven committed Parquet artifacts under data/processed/; this notebook reads only those.


2 Setup

Show code
suppressPackageStartupMessages({
  library(arrow)
  library(dplyr)
  library(ggplot2)
  library(tidyr)
  library(stringr)
  library(forcats)
  library(scales)
  library(sf)
  library(leaflet)
  library(reticulate)
})

reticulate::py_require(c("pandas", "numpy", "scikit-learn", "matplotlib"))

theme_set(
  theme_minimal(base_size = 12) +
    theme(
      plot.background  = element_rect(fill = "#fbfaf7", colour = NA),
      panel.background = element_rect(fill = "#fbfaf7", colour = NA),
      panel.grid.major = element_line(colour = "#e8e5dd"),
      panel.grid.minor = element_line(colour = "#f3f1ec"),
      text          = element_text(colour = "#14181f"),
      axis.text     = element_text(colour = "#4a5160"),
      plot.title    = element_text(colour = "#14181f", face = "bold"),
      plot.subtitle = element_text(colour = "#4a5160")
    )
)

# Method packages — installed on first render if absent.
for (pkg in c("kableExtra")) {
  if (!requireNamespace(pkg, quietly = TRUE)) {
    install.packages(pkg, repos = "https://cloud.r-project.org")
  }
}

# Lightweight wrapper for hero tables — bootstrap styling, no monospaced
# greyscale dump. Plain knitr::kable() still works elsewhere for inline /
# diagnostic tables where the styled version is overkill.
pretty_kable <- function(x, caption = NULL, ...) {
  knitr::kable(x, caption = caption, ...) |>
    kableExtra::kable_styling(
      bootstrap_options = c("striped", "hover", "condensed", "responsive"),
      full_width        = FALSE,
      position          = "left",
      font_size         = 13
    )
}

data_dir     <- here::here("traces", "applied", "02-medicaid-outliers", "data", "processed")
outliers     <- arrow::read_parquet(file.path(data_dir, "outliers.parquet"))
hcpcs_sum    <- arrow::read_parquet(file.path(data_dir, "hcpcs_summary.parquet"))
state_sum    <- arrow::read_parquet(file.path(data_dir, "state_summary.parquet"))
specialty_sum<- arrow::read_parquet(file.path(data_dir, "specialty_summary.parquet"))
taxonomy     <- arrow::read_parquet(file.path(data_dir, "taxonomy.parquet"))

format_money <- function(x) scales::label_dollar(accuracy = 1, scale_cut = scales::cut_short_scale())(x)
Show code
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler

plt.rcParams.update({
    "figure.dpi": 110,
    "figure.facecolor": "#fbfaf7",
    "axes.facecolor": "#fbfaf7",
    "savefig.facecolor": "#fbfaf7",
    "axes.edgecolor": "#4a5160",
    "axes.labelcolor": "#14181f",
    "axes.titlecolor": "#14181f",
    "text.color": "#14181f",
    "xtick.color": "#4a5160",
    "ytick.color": "#4a5160",
    "grid.color": "#e8e5dd",
})

3 Step 1 — what does the dataset actually contain?

Before any analysis, look at the raw shape of the data: how many providers, how many billing codes, how much money in total, how many candidate “outlier” rows the prep step already flagged for closer inspection. Doing this first catches obvious data-quality problems (missing files, truncated reads, accidentally-empty columns) before they propagate through the rest of the analysis.

Show code
summary_tbl <- tibble::tibble(
  hcpcs_n       = nrow(hcpcs_sum),
  providers_n   = outliers |> dplyr::pull(npi) |> dplyr::n_distinct(),
  total_paid    = sum(hcpcs_sum$total_paid, na.rm = TRUE),
  total_bene    = sum(hcpcs_sum$total_bene, na.rm = TRUE),
  flagged_ge_5  = sum(outliers$robust_z >= 5, na.rm = TRUE)
)

knitr::kable(
  summary_tbl |>
    mutate(total_paid = format_money(total_paid),
           across(where(is.numeric), \(x) format(round(x, 2), big.mark = ","))),
  caption = "Cohort sketch from DuckDB prep outputs"
)
Cohort sketch from DuckDB prep outputs
hcpcs_n providers_n total_paid total_bene flagged_ge_5
4,114 385,504 $1T 10,784,944,904 262,928

4 Step 2 — exploratory data analysis

This is the diagnostic phase. We’re asking four questions before trusting any downstream model:

  1. Are the seven Parquet files the size and shape we expect? If a join failed or a file got truncated in the prep step, we want to know now.
  2. Is the spending distribution actually heavy-tailed? This is the key empirical claim that motivates using the robust z-score instead of a regular z-score. If the data were close to a bell curve, the regular z-score would be fine and the extra machinery would be unnecessary.
  3. How complete is the geocoding join? If most provider addresses didn’t resolve to a county centroid, a county-level map will be hollow even where the data has signal.
  4. Where does the observed robust-z distribution sit relative to the conventional z ≥ 5 flag threshold? (The histogram below shows the empirical distribution of robust-z — actual data, including any real outliers. It is not the “null distribution,” which would be the hypothetical distribution if every provider were exactly typical. The shape and tail of the observed distribution tells us how aggressive our flag threshold is.)
Show code
artifact_summary <- function(name, df) {
  tibble::tibble(
    artifact     = name,
    rows         = nrow(df),
    cols         = ncol(df),
    n_complete   = sum(stats::complete.cases(df)),
    pct_complete = 100 * n_complete / pmax(nrow(df), 1)
  )
}

dplyr::bind_rows(
  artifact_summary("hcpcs_summary",    hcpcs_sum),
  artifact_summary("state_summary",    state_sum),
  artifact_summary("specialty_summary",specialty_sum),
  artifact_summary("outliers",         outliers),
  artifact_summary("taxonomy",         taxonomy)
) |>
  dplyr::mutate(
    rows         = format(rows, big.mark = ","),
    cols         = format(cols, big.mark = ","),
    n_complete   = format(n_complete, big.mark = ","),
    pct_complete = sprintf("%.1f%%", pct_complete)
  ) |>
  knitr::kable(
    col.names = c("Parquet artifact", "Rows", "Cols",
                  "Complete-case rows", "% complete-case"),
    caption = "Row × column shape and complete-case rate of each prep artifact. Aggregates (hcpcs / state / specialty summary) are dense by construction; the outliers detail table carries some NA in lat/lon (provider ZIP didn't resolve to a Census centroid)."
  )
Row × column shape and complete-case rate of each prep artifact. Aggregates (hcpcs / state / specialty summary) are dense by construction; the outliers detail table carries some NA in lat/lon (provider ZIP didn’t resolve to a Census centroid).
Parquet artifact Rows Cols Complete-case rows % complete-case
hcpcs_summary 4,114 6 4,114 100.0%
state_summary 84 7 84 100.0%
specialty_summary 768 9 768 100.0%
outliers 1,089,059 14 1,061,399 97.5%
taxonomy 879 4 639 72.7%
Show code
paid_per_bene_plot <- ggplot(
  outliers |> dplyr::filter(paid_per_bene > 0, !is.na(paid_per_bene)),
  aes(paid_per_bene)
) +
  geom_histogram(fill = "#1f4f99", color = "white", bins = 60) +
  scale_x_log10(labels = scales::label_dollar()) +
  labs(x = "Paid per beneficiary (log10)", y = "Rows", subtitle = "Per-row paid/bene")

bene_plot <- ggplot(
  outliers |> dplyr::filter(beneficiaries > 0, beneficiaries <= 5000),
  aes(beneficiaries)
) +
  geom_histogram(fill = "#1f4f99", color = "white", bins = 60) +
  labs(x = "Beneficiaries (≤ 5,000)", y = "Rows", subtitle = "Per-row beneficiary count")

peer_plot <- ggplot(hcpcs_sum, aes(peer_n)) +
  geom_histogram(fill = "#1f4f99", color = "white", bins = 60) +
  scale_x_log10(labels = scales::label_comma()) +
  labs(x = "Peer providers per HCPCS (log10)", y = "HCPCS codes",
       subtitle = "Peer-group sizes")

z_plot <- ggplot(
  outliers |> dplyr::filter(!is.na(robust_z),
                            robust_z >= -10, robust_z <= 50),
  aes(robust_z)
) +
  geom_histogram(fill = "#1f4f99", color = "white", bins = 60) +
  geom_vline(xintercept = 5, linetype = "dashed",
             color = "#be123c", linewidth = 0.6) +
  labs(x = "Robust z (clipped to [-10, 50])", y = "Rows",
       subtitle = "Robust-z distribution")

# Use patchwork if available, then gridExtra, then a plain stacked print.
if (requireNamespace("patchwork", quietly = TRUE)) {
  patchwork::wrap_plots(paid_per_bene_plot, bene_plot,
                        peer_plot, z_plot, ncol = 2)
} else if (requireNamespace("gridExtra", quietly = TRUE)) {
  gridExtra::grid.arrange(paid_per_bene_plot, bene_plot,
                          peer_plot, z_plot, ncol = 2)
} else {
  for (p in list(paid_per_bene_plot, bene_plot, peer_plot, z_plot)) print(p)
}

Three distributional sanity checks. Top-left — paid per beneficiary across all (NPI × HCPCS) rows on a log10 scale; the long upper tail is exactly why MAD-based robust z’s outperform a mean/SD z-score here. Top-right — beneficiary count per row, capped at 5,000 for visibility. Bottom-left — peer-group sizes per HCPCS (the denominators behind every robust z); HCPCS with peer_n < 25 are filtered out in prep. Bottom-right — robust-z values (those with a finite z) showing the long right tail; the orange dashed line marks the program-integrity ≥ 5 cutoff.
Show code
geo_cov <- outliers |>
  dplyr::summarise(
    rows_total      = dplyr::n(),
    rows_with_state = sum(!is.na(state)),
    rows_with_geo   = sum(!is.na(lat) & !is.na(lon)),
    rows_with_spec  = sum(!is.na(specialty_name))
  ) |>
  dplyr::mutate(
    pct_state = 100 * rows_with_state / rows_total,
    pct_geo   = 100 * rows_with_geo   / rows_total,
    pct_spec  = 100 * rows_with_spec  / rows_total
  )

cat(sprintf(paste0(
    "Outlier-table rows total            : %s\n",
    "  with NPPES state                  : %s (%.1f%%)\n",
    "  with NPPES specialty              : %s (%.1f%%)\n",
    "  with ZCTA → lat/lon centroid      : %s (%.1f%%)\n"),
  format(geo_cov$rows_total,      big.mark = ","),
  format(geo_cov$rows_with_state, big.mark = ","), geo_cov$pct_state,
  format(geo_cov$rows_with_spec,  big.mark = ","), geo_cov$pct_spec,
  format(geo_cov$rows_with_geo,   big.mark = ","), geo_cov$pct_geo
))
Outlier-table rows total            : 1,089,059
  with NPPES state                  : 1,076,259 (98.8%)
  with NPPES specialty              : 1,076,255 (98.8%)
  with ZCTA → lat/lon centroid      : 1,061,403 (97.5%)

The geo-coverage line is the join-quality check that any geographic claim downstream depends on: a county-level atlas built from a 60% NPPES-state coverage would mean the map is showing 60% of the data, not 100% — and the difference matters for any conclusion about state-level Medicaid policy.

4.1 Overdispersion and zero-inflation diagnostics

Some of the columns in our data are counts — number of beneficiaries served, number of claims billed, number of months active. If we ever wanted to model these counts (predict them as a function of provider characteristics), we’d need to pick a probability distribution to assume. The simplest count model, the Poisson, has the curious property that its variance equals its mean — which is rarely true in real data. Two diagnostics check whether Poisson is even worth considering:

  • The variance-to-mean ratio (VMR) computes \(\text{Var}(X)/E[X]\). Under Poisson this ratio is 1; a VMR much greater than 1 means the data are overdispersed and a negative binomial (NB) distribution would fit better.
  • The fraction of zeros tells us whether the count distribution has more zeros than even NB can produce — in which case a zero-inflated NB model would be appropriate.

We’re not actually fitting a count model here (the outlier-flagging is a different task), but documenting these diagnostics is good practice: it tells any future modeller exactly what they’re walking into.

Show code
vmr <- function(x) {
  x <- x[!is.na(x) & x >= 0]
  if (length(x) == 0 || mean(x) == 0) return(c(mean = NA, var = NA, vmr = NA))
  c(mean = mean(x), var = stats::var(x), vmr = stats::var(x) / mean(x))
}
zero_pct <- function(x) {
  x <- x[!is.na(x)]
  if (!length(x)) return(NA_real_)
  100 * mean(x == 0)
}

vmr_tbl <- rbind(
  paid          = vmr(outliers$paid),
  beneficiaries = vmr(outliers$beneficiaries),
  claims        = vmr(outliers$claims),
  months_active = vmr(outliers$months_active)
)

family_call <- function(v) {
  if (is.na(v)) return("—")
  if (v < 1.2)              "Poisson"
  else if (v < 5)           "Quasi-Poisson / NB"
  else                      "Negative binomial / ZINB"
}

pretty_kable(
  data.frame(
    Variable     = rownames(vmr_tbl),
    Mean         = sprintf("%.1f", vmr_tbl[, "mean"]),
    Variance     = sprintf("%.1f", vmr_tbl[, "var"]),
    VMR          = sprintf("%.1f", vmr_tbl[, "vmr"]),
    `% zeros`    = sprintf("%.2f%%",
                            vapply(list(outliers$paid, outliers$beneficiaries,
                                        outliers$claims, outliers$months_active),
                                   zero_pct, numeric(1))),
    `Family`     = vapply(vmr_tbl[, "vmr"], family_call, character(1)),
    check.names = FALSE
  ),
  caption = "Variance-to-mean ratio (VMR), zero-inflation, and the implied regression family for each count-like feature. Caveat: the outliers parquet is already filtered to robust_z >= 2 OR top-20 spend codes AND beneficiaries >= 11 in prep, so the % zeros reads near 0 by construction. The VMR is still informative because it characterizes the conditional dispersion that any future modeling step will need to absorb."
)
Variance-to-mean ratio (VMR), zero-inflation, and the implied regression family for each count-like feature. Caveat: the outliers parquet is already filtered to robust_z >= 2 OR top-20 spend codes AND beneficiaries >= 11 in prep, so the % zeros reads near 0 by construction. The VMR is still informative because it characterizes the conditional dispersion that any future modeling step will need to absorb.
Variable Mean Variance VMR % zeros Family
paid paid 610931.6 115566678372174.3 189164684.9 1.62% Negative binomial / ZINB
beneficiaries beneficiaries 2740.6 253168488.7 92375.8 0.00% Negative binomial / ZINB
claims claims 5845.1 8580957013.7 1468053.1 0.00% Negative binomial / ZINB
months_active months_active 26.5 753.9 28.4 0.00% Negative binomial / ZINB

The Poisson distribution with rate \(\lambda\) has the curious property that its variance equals its mean: \(\Pr(X = k) = \lambda^k e^{-\lambda}/k!\), and from the moment-generating function \(E[X] = \lambda\) and \(\text{Var}(X) = \lambda\). So VMR = variance/mean = 1 in expectation; a sample with VMR much above 1 falsifies the Poisson assumption — the data are overdispersed. Negative binomial (NB) is the standard fix, built as a Poisson-Gamma mixture: \(X \mid \mu \sim \text{Poisson}(\mu)\) where \(\mu\) itself varies as \(\text{Gamma}(\theta)\). The marginal has \(\text{Var}(X) = \mu_0 + \mu_0^2/\theta\) — the extra term absorbs unobserved heterogeneity across observations. Zero-inflated NB layers a second mechanism on top: with probability \(\pi\) the observation is a structural zero (forced to \(X = 0\)), and with probability \(1 - \pi\) it’s an ordinary NB draw. Use ZINB when zeros are categorically different from small counts (a doctor who never prescribes opioids vs. one who prescribes them rarely). A hurdle model is similar but cleaner: model “any vs. none” with one distribution, then conditional on positive, model “how much” with a truncated NB — useful when enrollment and intensity are governed by different processes. As a concrete example, 1,000 providers each filing a beneficiary count with \(E[X] = 40\) and \(\text{Var}(X) = 380\) has VMR = 9.5, strongly overdispersed; the NB fit solves \(380 = 40 + 1600/\theta\) for \(\theta \approx 4.7\).

4.2 Within-flagged-set selection bias

The outliers parquet is itself a non-random sample of the full DuckDB output (kept rows are those with robust_z >= 2 OR billed under the top-20 spending HCPCS codes). We can’t directly compare against the dropped rows without re-running the 11 GB DuckDB pipeline, but we can check whether the high-z subset (robust_z >= 5, the program-integrity threshold) systematically differs from the moderate-z subset (2 <= robust_z < 5) on the variables we’d otherwise model — a “what makes the flagged-flagged set different from the merely flagged” check. The seven tests below are descriptive: independence assumptions are mildly violated (some NPIs appear in multiple rows on different HCPCS), and we do not apply a multiplicity correction across the seven, so the p-values should be read as evidence of direction rather than as calibrated significance tests.

Show code
with_groups <- outliers |>
  dplyr::filter(!is.na(robust_z), robust_z >= 2) |>
  dplyr::mutate(
    z_group = dplyr::if_else(robust_z >= 5, "High z (>= 5)", "Moderate z (2 - 5)")
  )

n_hi  <- sum(with_groups$z_group == "High z (>= 5)")
n_mod <- sum(with_groups$z_group == "Moderate z (2 - 5)")

cont_results <- list(
  beneficiaries  = stats::wilcox.test(beneficiaries  ~ z_group, data = with_groups),
  claims         = stats::wilcox.test(claims         ~ z_group, data = with_groups),
  paid           = stats::wilcox.test(paid           ~ z_group, data = with_groups),
  paid_per_bene  = stats::wilcox.test(paid_per_bene  ~ z_group, data = with_groups),
  months_active  = stats::wilcox.test(months_active  ~ z_group, data = with_groups)
)

state_tab <- table(with_groups$state, with_groups$z_group)
spec_tab  <- table(with_groups$specialty_class, with_groups$z_group)

cat_tests <- list(
  state           = suppressWarnings(stats::chisq.test(state_tab)),
  specialty_class = suppressWarnings(stats::chisq.test(spec_tab))
)

bias_df <- data.frame(
  Variable    = c(names(cont_results), names(cat_tests)),
  Test        = c(rep("Mann-Whitney U", length(cont_results)),
                  rep("Pearson chi-squared", length(cat_tests))),
  Statistic   = c(
    vapply(cont_results, \(r) round(as.numeric(r$statistic), 0), numeric(1)),
    vapply(cat_tests,    \(r) round(as.numeric(r$statistic), 1), numeric(1))
  ),
  `p-value`   = c(
    vapply(cont_results, \(r) format.pval(r$p.value, eps = 1e-300, digits = 2),
           character(1)),
    vapply(cat_tests,    \(r) format.pval(r$p.value, eps = 1e-300, digits = 2),
           character(1))
  ),
  check.names = FALSE
)

cat(sprintf("High-z (>= 5)         : %s rows\nModerate-z (2 - 5)    : %s rows\n\n",
            format(n_hi,  big.mark = ","),
            format(n_mod, big.mark = ",")))
High-z (>= 5)         : 262,928 rows
Moderate-z (2 - 5)    : 360,373 rows
Show code
knitr::kable(
  bias_df,
  caption = "Within-flagged-set bias check: do high-z (>= 5) rows differ from moderate-z (2 - 5) rows on the inputs that would feed any peer-comparison model? The 'high-z is just larger providers' null can be ruled out by checking the beneficiary and claim distributions; specialty mix differences expose which clinical areas drive the high-z tail."
)
Within-flagged-set bias check: do high-z (>= 5) rows differ from moderate-z (2 - 5) rows on the inputs that would feed any peer-comparison model? The ‘high-z is just larger providers’ null can be ruled out by checking the beneficiary and claim distributions; specialty mix differences expose which clinical areas drive the high-z tail.
Variable Test Statistic p-value
beneficiaries beneficiaries Mann-Whitney U 4.622618e+10 2.2e-60
claims claims Mann-Whitney U 4.574995e+10 7.3e-119
paid paid Mann-Whitney U 4.271721e+10 <1e-300
paid_per_bene paid_per_bene Mann-Whitney U 4.170076e+10 <1e-300
months_active months_active Mann-Whitney U 4.640249e+10 6.1e-44
state state Pearson chi-squared 1.421810e+04 <1e-300
specialty_class specialty_class Pearson chi-squared 3.494460e+04 <1e-300
Show code
hcpcs_totals <- hcpcs_sum |>
  arrange(desc(total_paid)) |>
  mutate(
    rank_pct  = row_number() / n(),
    cum_share = cumsum(total_paid) / sum(total_paid)
  )

ggplot(hcpcs_totals, aes(rank_pct, cum_share)) +
  geom_line(color = "#1f4f99", linewidth = 1) +
  geom_hline(yintercept = 0.8, linetype = "dashed", color = "#be123c") +
  scale_x_continuous(labels = percent_format()) +
  scale_y_continuous(labels = percent_format()) +
  labs(x = "Fraction of HCPCS codes (sorted from top to bottom spender)",
       y = "Cumulative share of total paid")

Cumulative share of total Medicaid paid dollars, ordered by HCPCS code from highest- to lowest-spending. A steep early curve means a small set of codes drives most public outlay.

5 Step 3 — peer-group outlier surfacing (exploratory)

This is the analytical heart of the notebook. Our goal is to identify (provider, billing-code) pairs where the payment per patient is much higher than what other providers billing the same code received — controlling for the fact that we’re running this comparison for hundreds of thousands of rows, so some will look extreme just by chance.

The recipe is:

  1. For each billing code (HCPCS), compute the median paid-per-beneficiary across all providers billing that code. This is the peer-group reference value.
  2. For each provider-billing-code row, compute a robust z-score: how many normal-scale “standard deviations” above the peer median is this row, using the median absolute deviation (MAD) as the scale.
  3. Convert each robust z-score into a tail probability using the standard normal distribution as a reference.
  4. Apply the Benjamini–Hochberg correction to control the expected false-discovery proportion across all the rows being tested.

In one sentence. Compute a MAD-based robust z per (NPI × HCPCS) row, then apply Benjamini–Hochberg FDR ≤ 0.01 instead of a hard z-cutoff so the flag list comes with a multiplicity guarantee, not just an arbitrary threshold.

WarningThis is surfacing, not detection — read the flag list as exploratory

Two reasons the framing matters for a portfolio reader:

  1. The peer group is too broad. “Everyone billing this HCPCS” mixes specialties, panel sizes, and care settings into one comparison. A correct peer group is specialty × panel-size band × setting. This is the foundational caveat in the README and it isn’t resolved in the current pipeline — every flagged outlier could be explained by “wrong peer group” before “anomalous billing behaviour.” A publication-grade or deployable version of this analysis would require a re-run with that peer-group adjustment as the first step.
  2. No ground-truth validation. The flag list has not been compared against the HHS-OIG List of Excluded Individuals/Entities (LEIE) or any other ground-truth set of providers known to have been sanctioned. The robust-z + BH-FDR output is therefore a prioritisation tool — “look at these rows first” — not a detector whose precision and recall have been measured.

Read every “flagged” count below in that frame: surfaced for review, not confirmed as anomalous. The methodology is correct; the validation step that turns it into a deployable detector is the missing piece.

NoteWhy a robust (MAD-based) z and not a normal z?

The classical z-score is (x − mean) / SD. It works beautifully when the data are roughly normal. It fails completely on heavy-tailed data because a few extreme outliers blow up the SD, which then deflates every other observation’s z toward zero — the outliers hide themselves by inflating the noise scale.

The robust z replaces the two non-robust pieces:

  • Median instead of mean (robust to outliers; doesn’t move when you change extreme values)
  • MAD (median absolute deviation) instead of SD (also robust). Multiplied by 1.4826 so that under a normal distribution, MAD × 1.4826 estimates the SD — making the resulting z-score interpretable on the familiar scale (“z = 5 means about 5 SDs from the median”).

Use robust z whenever your data have heavy tails (claims data, gene expression, financial returns, anything log-skewed). Use the classical z when you’ve genuinely confirmed normality (rare in real-world health data). (Hampel 1974; Rousseeuw & Croux 1993)

The MAD by itself is a small number, not the same scale as a standard deviation; 1.4826 is the rescaling factor that makes MAD estimate \(\sigma\) if your data is normally distributed. The derivation: for \(X \sim N(\mu, \sigma^2)\), the absolute deviation \(|X - \mu|\) has a half-normal distribution. The median of \(|X - \mu|\) satisfies \(\Pr(|X - \mu| \le \text{MAD}) = \tfrac{1}{2}\). Since the standardised \(|Z| = |X - \mu|/\sigma\) has CDF \(2\Phi(z) - 1\), setting this equal to \(\tfrac{1}{2}\) gives \(\Phi(\text{MAD}/\sigma) = \tfrac{3}{4}\), so \(\text{MAD}/\sigma = \Phi^{-1}(0.75) \approx 0.6745\), and \(\sigma \approx \text{MAD}/0.6745 = 1.4826 \cdot \text{MAD}\). Exact for normal data; approximate otherwise. The corresponding “robust z” \(= (x - \text{median})/(1.4826 \cdot \text{MAD})\) reads on the same scale as a regular z-score but is built from quantities with high breakdown point — the fraction of arbitrarily bad observations an estimator tolerates before producing nonsense. The mean has breakdown 0 (one bad point moves it arbitrarily); both median and MAD have breakdown 0.5. Example: for \(\{10, 11, 12, \ldots, 18, 1000\}\), mean = 112.6, SD ≈ 313, so the classical z for 1000 is just 2.83 (barely flagged — the outlier inflated its own scale). The median is 14.5, MAD = 2.5, so robust z = 266 — the outlier shows up as obviously extreme because the scale didn’t bend to accommodate it.

NoteWhy Benjamini–Hochberg FDR — and what does “FDR ≤ 0.01” actually mean?

When you test thousands of hypotheses, some will look significant just by chance. At p < 0.05 across 100,000 tests, you’d expect 5,000 false positives even if no real signal exists. A hard z ≥ 5 cutoff has the same problem: at z = 5 in a normal null, p ≈ 6 × 10⁻⁷, but across 100,000 tests you’d still expect ~0.06 false positives per HCPCS code — and the data have hundreds of HCPCS codes.

The Benjamini–Hochberg (BH) procedure controls the false discovery rate (FDR) — the expected fraction of false positives among the rows you flag. “FDR ≤ 0.01” means: of the rows the rule flags, no more than 1% are expected to be false positives. That’s a much more honest claim than “p < 0.05” on each individual test.

How BH works: sort the p-values from smallest to largest. For the i-th smallest, compare it to (i / m) × α, where m is the total number of tests and α is your target FDR. The largest i where the test passes defines the cutoff; flag everything at or below it. Built into base R as p.adjust(method = "BH").

Use BH whenever you’re testing many hypotheses simultaneously — genomics, neuroimaging, fraud detection, A/B-testing dashboards. Bonferroni (divide α by m) is more conservative; use Bonferroni when you need a strict family-wise error rate guarantee, BH when controlling the false discovery proportion is enough. (Benjamini & Hochberg 1995; Benjamini & Yekutieli 2001)

Two facts power BH. First, under the null, p-values are uniformly distributed on \([0,1]\), so among \(m_0\) true nulls, the expected number with \(p \leq t\) is exactly \(m_0 t\). Second, the BH cutoff is set so that if we reject the smallest \(i\) p-values, the expected count of false rejections among them is at most \(\alpha i\). The algorithm: sort p-values \(p_{(1)} \leq p_{(2)} \leq \cdots \leq p_{(m)}\), find the largest \(i^*\) with \(p_{(i^*)} \leq (i^*/m) \cdot \alpha\), and reject everything up to \(i^*\). Equivalently, define the q-value \(q_{(i)} = \min_{j \geq i} m \cdot p_{(j)}/j\) and reject all tests with \(q \leq \alpha\). Under independence (or “positive regression dependency”), Benjamini & Hochberg proved \(\text{FDR} = E[V / \max(R, 1)] \leq (m_0/m) \cdot \alpha \leq \alpha\), where \(V\) is false discoveries and \(R\) is total discoveries — a martingale argument on the ordered p-values. Contrast with Bonferroni: Bonferroni rejects \(p \leq \alpha/m\) for every test, controlling the family-wise error rate (probability of any false rejection); much stricter, much less powerful. As a 10-test example with \(\alpha = 0.05\): p-values \((0.001, 0.008, 0.012, 0.04, 0.05, 0.10, \ldots)\) compared against the cutoffs \((0.005, 0.010, 0.015, 0.020, 0.025, \ldots)\) — the first three pass the linear BH cutoff while the fourth (\(p = 0.04 > 0.020\)) does not. BH rejects 3 hypotheses; Bonferroni at the same \(\alpha\) would reject only the very first (\(p < 0.005\)). BH finds 2 extra real discoveries while keeping the expected false-discovery proportion at 5%.

The DuckDB prep step computed a robust z-score per HCPCS using the median absolute deviation (MAD) scaled by 1.4826. Under normality, a robust z of 5 corresponds to roughly five standard deviations above the peer median — a common heuristic threshold for program-integrity screens. On heavy-tailed spending data the correspondence is approximate (extreme values are intrinsically more common than a normal would predict), so a fixed z-cutoff at any level is a heuristic rather than a calibrated test.

To guard against false alarms from running hundreds of thousands of comparisons, we also compute Benjamini–Hochberg-adjusted q-values and flag at q ≤ 0.01. Two caveats are essential to read these q-values correctly. First, the p-values feeding BH come from a standard-normal reference applied to robust z’s whose true null distribution is heavier-tailed than normal — so the p-values systematically understate tail probabilities, and the strict FDR-control guarantee doesn’t hold at the nominal level. Second, the outliers parquet was pre-filtered upstream to keep rows with robust_z ≥ 2 or membership in the top-20 spend codes; applying BH within this pre-filtered family controls the false-discovery proportion given the filter, not over the full population of providers. The honest reading: the q-values rank rows by extremity in a multiplicity-aware way, and the resulting list is a defensible prioritisation for human review — not a calibrated detector.

Show code
flagged <- outliers |>
  dplyr::filter(!is.na(robust_z), beneficiaries >= 30) |>
  dplyr::mutate(
    # One-sided upper-tail p-value from a standard-normal reference,
    # consistent with our one-sided flag (only over-billing is of
    # interest). pnorm(-z) = Pr(Z > z) for standard normal Z.
    # Caveat: the true null distribution of robust z on heavy-tailed
    # spending data has fatter tails than normal, so this approximation
    # UNDERSTATES the true tail probability for large z. The reported
    # q-values are therefore not strictly FDR-controlling at the nominal
    # level on the global universe of all NPI x HCPCS rows; they are
    # an empirical prioritisation score, not calibrated p-values.
    p_value  = stats::pnorm(-robust_z),
    q_value  = stats::p.adjust(p_value, method = "BH"),
    flag_z5  = robust_z >= 5,
    flag_fdr = q_value <= 0.01
  )

flagging_summary <- data.frame(
  Rule                 = c("Raw z >= 5",
                            "BH-FDR q <= 0.01 (one-sided)",
                            "Both rules agree (intersection)"),
  Flagged              = c(sum(flagged$flag_z5),
                            sum(flagged$flag_fdr),
                            sum(flagged$flag_z5 & flagged$flag_fdr))
)
flagging_summary$`% of tested` <- sprintf("%.2f%%",
  100 * flagging_summary$Flagged / nrow(flagged))

pretty_kable(
  flagging_summary,
  caption = sprintf(
    "Flagging counts for %s NPI x HCPCS rows tested (after the beneficiaries >= 30 minimum-sample filter and the upstream prep-step filter of robust_z >= 2 OR top-20 spend codes). The raw z >= 5 cutoff is a common program-integrity heuristic. The BH-FDR rule controls the expected false-discovery proportion *within this pre-filtered family*; because the family was selected on the test statistic, the q-values are not globally calibrated, and we treat them as a prioritisation score rather than calibrated probabilities. The intersection is the highest-confidence subset for downstream review.",
    format(nrow(flagged), big.mark = ",")
  )
)
Flagging counts for 907,705 NPI x HCPCS rows tested (after the beneficiaries >= 30 minimum-sample filter and the upstream prep-step filter of robust_z >= 2 OR top-20 spend codes). The raw z >= 5 cutoff is a common program-integrity heuristic. The BH-FDR rule controls the expected false-discovery proportion *within this pre-filtered family*; because the family was selected on the test statistic, the q-values are not globally calibrated, and we treat them as a prioritisation score rather than calibrated probabilities. The intersection is the highest-confidence subset for downstream review.
Rule Flagged % of tested
Raw z >= 5 214160 23.59%
BH-FDR q <= 0.01 (one-sided) 391206 43.10%
Both rules agree (intersection) 214160 23.59%
Show code
top_outliers <- flagged |>
  filter(flag_fdr) |>
  arrange(q_value, desc(robust_z)) |>
  slice_head(n = 25) |>
  transmute(
    npi, state, specialty_name, hcpcs,
    beneficiaries, paid,
    paid_per_bene       = paid / beneficiaries,
    hcpcs_median_ppb,
    robust_z            = round(robust_z, 1),
    q_value             = format.pval(q_value, eps = 1e-300, digits = 2)
  )

pretty_kable(
  top_outliers |>
    mutate(
      paid              = format_money(paid),
      paid_per_bene     = format_money(paid_per_bene),
      hcpcs_median_ppb  = format_money(hcpcs_median_ppb)
    ),
  caption = "Top 25 peer-group outliers ranked by BH-FDR q-value (FDR ≤ 0.01, ≥ 30 beneficiaries). The q-value column is the false-discovery-adjusted p — these are the rows the multiplicity-controlled rule keeps."
)
Top 25 peer-group outliers ranked by BH-FDR q-value (FDR ≤ 0.01, ≥ 30 beneficiaries). The q-value column is the false-discovery-adjusted p — these are the rows the multiplicity-controlled rule keeps.
npi state specialty_name hcpcs beneficiaries paid paid_per_bene hcpcs_median_ppb robust_z q_value
1942644661 FL Exclusive Provider Organization S0516 210 $47K $222 $16 7.806489e+16 <1e-300
1174863666 NJ Local Education Agency (LEA) 99361 41 $80K $2K $2K 5.176224e+14 <1e-300
1154532075 NJ Local Education Agency (LEA) 99361 83 $157K $2K $2K 3.196162e+14 <1e-300
1225423106 NJ Case Management Agency 99361 76 $141K $2K $2K 2.094327e+14 <1e-300
1215414578 KY Optometrist S0516 33 $543 $16 $16 1.838690e+14 <1e-300
1407299464 NJ Local Education Agency (LEA) 99361 65 $120K $2K $2K 1.632501e+14 <1e-300
1295225357 KY Optometrist S0516 33 $541 $16 $16 1.525722e+14 <1e-300
1821098401 KY Optometrist S0516 91 $1K $16 $16 1.333556e+14 <1e-300
1891796314 KY Optometrist S0516 55 $895 $16 $16 1.103214e+14 <1e-300
1225377542 NJ Local Education Agency (LEA) 99361 99 $181K $2K $2K 1.071844e+14 <1e-300
1063752889 NJ Local Education Agency (LEA) 99361 104 $190K $2K $2K 1.020313e+14 <1e-300
1912049511 KY Optometrist S0516 191 $3K $16 $16 9.530385e+13 <1e-300
1780923094 NJ Local Education Agency (LEA) 99361 307 $558K $2K $2K 8.641090e+13 <1e-300
1326031527 KY Optometrist S0516 261 $4K $16 $16 6.974343e+13 <1e-300
1639342652 NJ Local Education Agency (LEA) 99361 413 $746K $2K $2K 5.138624e+13 <1e-300
1619217585 NJ Local Education Agency (LEA) 99361 112 $202K $2K $2K 4.737169e+13 <1e-300
1144560657 NJ Local Education Agency (LEA) 99361 226 $408K $2K $2K 4.695247e+13 <1e-300
1710008974 NJ Local Education Agency (LEA) 99361 1029 $2M $2K $2K 4.640492e+13 <1e-300
1730303611 NJ Local Education Agency (LEA) 99361 572 $1M $2K $2K 4.637788e+13 <1e-300
1992044283 NJ Local Education Agency (LEA) 99361 606 $1M $2K $2K 4.377582e+13 <1e-300
1063751162 NJ Local Education Agency (LEA) 99361 249 $449K $2K $2K 4.261550e+13 <1e-300
1184963431 NJ Local Education Agency (LEA) 99361 784 $1M $2K $2K 4.060431e+13 <1e-300
1669711057 NJ Local Education Agency (LEA) 99361 325 $585K $2K $2K 3.265003e+13 <1e-300
1235356627 NJ Local Education Agency (LEA) 99361 830 $1M $2K $2K 3.196162e+13 <1e-300
1730429127 NJ Local Education Agency (LEA) 99361 333 $599K $2K $2K 3.186564e+13 <1e-300
Show code
top_specialties <- specialty_sum |>
  arrange(desc(flagged_ge_5)) |>
  slice_head(n = 15) |>
  pull(specialty_name)

box_df <- specialty_sum |>
  filter(specialty_name %in% top_specialties) |>
  mutate(specialty_name = fct_reorder(specialty_name, p50))

jitter_df <- outliers |>
  filter(specialty_name %in% top_specialties, robust_z >= 5) |>
  mutate(specialty_name = factor(specialty_name, levels = levels(box_df$specialty_name)))

ggplot(box_df, aes(specialty_name)) +
  geom_boxplot(
    aes(
      ymin   = p05,  lower  = p25,
      middle = p50,  upper  = p75,
      ymax   = p95
    ),
    stat = "identity",
    fill = "#e1e7f3", color = "#1f4f99", width = 0.7
  ) +
  geom_jitter(data = jitter_df,
              aes(y = paid_per_bene),
              color = "#be123c", width = 0.2, size = 0.7, alpha = 0.5) +
  scale_y_log10(labels = scales::label_dollar()) +
  coord_flip() +
  labs(x = NULL, y = "Paid per beneficiary (log scale)")

Per-beneficiary spend by specialty (top 15 by flagged-outlier count). Box spans p25–p75 with median; whiskers are p05 / p95 from the full peer distribution computed in the DuckDB prep step. Orange dots are the individual robust-z ≥ 5 outliers layered on top.

6 Step 4 — a second opinion: unsupervised anomaly detection

NoteWhy an isolation forest as a second opinion?

The robust z asks one question: is this row’s paid-per-beneficiary unusually far from the peer median? It looks at one variable at a time. Isolation forest asks a richer question: is this row unusual considering the joint distribution of all features at once? A row could look normal on every individual feature but still be an outlier in the joint space (high beneficiaries + low claims + high paid is a different story from high beneficiaries + high claims + high paid).

How it works: the algorithm builds many random binary trees by splitting on random features at random thresholds. Outliers are easier to isolate — they get separated into their own leaf in fewer splits. The “anomaly score” for a row is roughly the average path length from root to its leaf across the trees; shorter path = more anomalous. No labels needed.

Use unsupervised methods (isolation forest, local outlier factor, one-class SVM, autoencoders) when you don’t have a labeled training set of “fraud” vs “not fraud” — which is almost always the case in healthcare. Treat the agreement between a rule-based method (robust z) and an unsupervised method as your highest-confidence flag set, and the disagreement as the interesting set worth a human review. (Liu, Ting & Zhou 2008, 2012)

Each isolation tree is built by recursively splitting a random subsample of \(\psi\) observations on a random feature at a random cutoff. The split continues until every point is alone in its own leaf. “Normal” points require many splits to isolate (they have many neighbours, each requiring a cut); outliers — sitting in low-density regions — get isolated quickly with few splits. The path length \(h(x)\) from root to leaf is the diagnostic. To make path lengths comparable across sample sizes, divide by the expected path length of a binary search tree with \(\psi\) nodes: \[c(\psi) = 2\,H(\psi - 1) - \tfrac{2(\psi - 1)}{\psi}, \quad H(i) = \ln(i) + 0.5772 \text{ (Euler–Mascheroni)}.\] The anomaly score \(s(x, \psi) = 2^{-E[h(x)]/c(\psi)}\) then maps the unbounded path length onto \([0, 1]\) — values near 1 indicate strong anomalies (isolated near the root), values near 0 indicate likely inliers, and ~0.5 means average-depth (no signal). The contamination=0.01 parameter isn’t a property of the data; it’s an alert budget — flag the top 1% of scores for human review. Set higher when reviews are cheap, lower when false positives are expensive. Concrete numbers: with the default \(\psi = 256\), \(c(256) \approx 10.25\); a row with average path length 4 across 100 trees scores \(2^{-4/10.25} \approx 0.76\) (strong anomaly), while a row with path length 12 scores \(\approx 0.45\) (inlier-ish).

This step adds a second opinion: an isolation forest, an unsupervised tree-based anomaly detector that scores each row on how unusual it looks in the joint feature space. We fit it on the top 20 billing codes by total spend (where peer groups are large enough to be informative), standardise the features so they’re on comparable scales, and then look at the overlap between rows flagged by the isolation forest and rows flagged by the robust-z rule.

The two methods are doing different things: the robust-z is a deterministic, auditable per-code rule and the isolation forest is an unsupervised, joint-feature, probabilistic model. Where they agree, we have higher confidence; where they disagree, there’s usually something interesting going on (a row that’s extreme on one variable but typical on the others, for example).

6.1 Feature collinearity: a brief check before fitting

When two features are nearly perfect substitutes for each other (e.g., total claims and total beneficiaries — each claim usually covers about one beneficiary, so the two numbers move in lock-step), we say they are collinear. For a linear model, collinearity inflates the standard errors of the affected coefficients — the model can’t tell which feature is “doing the work.” Tree-based models like the isolation forest don’t have this problem in the same way: trees just pick whichever feature to split on, and substitutability doesn’t break inference. But heavy collinearity still has a subtler cost: it reduces tree diversity in the ensemble, because many trees will pick essentially the same split. Three diagnostics give a quick read on how bad the collinearity is:

  • Pearson correlation matrix — pairwise linear association, \(r \in [-1, 1]\). Values near \(\pm 1\) flag perfectly substitutable variables.
  • Condition number \(\kappa\) — the ratio of the largest to smallest singular value of the standardised design matrix. Conventional thresholds: \(\kappa < 10\) clean, \(10\)\(30\) moderate, \(> 30\) problematic.
  • Variance inflation factor (VIF) per feature — for each feature, regress it on all the others; VIF \(= 1/(1-R^2)\). The same idea as the condition number, but localised to each feature.
Show code
from sklearn.linear_model import LinearRegression

df = r.outliers.copy()
top_codes = (
    df.groupby("hcpcs")["paid"].sum().sort_values(ascending=False).head(20).index
)
sub = df[df["hcpcs"].isin(top_codes)].copy()
sub = sub.dropna(subset=["paid_per_bene", "beneficiaries", "claims", "months_active"])

feats   = ["paid_per_bene", "beneficiaries", "claims", "months_active"]
X_raw   = sub[feats]

print("Pearson correlation matrix among isolation-forest features:")
Pearson correlation matrix among isolation-forest features:
Show code
print(X_raw.corr(method="pearson").round(2).to_string())
               paid_per_bene  beneficiaries  claims  months_active
paid_per_bene           1.00           0.01    0.08           0.09
beneficiaries           0.01           1.00    0.46           0.30
claims                  0.08           0.46    1.00           0.10
months_active           0.09           0.30    0.10           1.00
Show code
Xs   = StandardScaler().fit_transform(X_raw.to_numpy())
sv   = np.linalg.svd(Xs, compute_uv=False)
cond = sv.max() / sv.min()
print(f"\nCondition number (kappa) of standardized design matrix: {cond:.1f}")

Condition number (kappa) of standardized design matrix: 1.8
Show code
print("Rule of thumb: kappa < 10 clean, 10-30 moderate, > 30 problematic.")
Rule of thumb: kappa < 10 clean, 10-30 moderate, > 30 problematic.
Show code
vifs = {}
for j, name in enumerate(feats):
    others = [k for k in range(len(feats)) if k != j]
    r2     = LinearRegression().fit(Xs[:, others], Xs[:, j]).score(Xs[:, others], Xs[:, j])
    vifs[name] = round(1.0 / max(1 - r2, 1e-9), 2)

print("\nApproximate VIF per feature (1 / (1 - R^2_other)):")

Approximate VIF per feature (1 / (1 - R^2_other)):
Show code
for k, v in vifs.items():
    print(f"  {k:<16s} : {v}")
  paid_per_bene    : 1.02
  beneficiaries    : 1.39
  claims           : 1.28
  months_active    : 1.11

What the numbers mean here: claims and beneficiaries are essentially the same construct measured two ways (each claim covers approximately one beneficiary), so r ≈ 0.95+ and a VIF in the double digits on both is the expected reading — not a bug. paid_per_bene is by construction orthogonal to beneficiaries (it’s paid divided by beneficiaries) and months_active carries an independent activity-duration axis. We keep all four features anyway: the isolation forest splits don’t over-count the redundant pair the way a linear regression would, and dropping claims would lose the small but real per-beneficiary multi-claim variation in the long tail. The condition-number print just makes that decision auditable rather than implicit.

A design matrix \(\mathbf{X}\) stretches different directions of input space by different amounts. The singular value decomposition \(\mathbf{X} = \mathbf{U}\mathbf{\Sigma}\mathbf{V}^\top\) writes those stretch factors as the singular values \(\sigma_1 \geq \sigma_2 \geq \cdots \geq \sigma_p \geq 0\) on the diagonal of \(\mathbf{\Sigma}\). The condition number is the ratio of the most-stretched direction to the least: \[\kappa(\mathbf{X}) = \sigma_1 / \sigma_p.\] A large \(\kappa\) means the matrix is nearly singular — small changes in \(y\) produce huge changes in \(\hat\beta\), because there’s a near-zero-stretch direction the predictions barely depend on. Concretely, the relative error in \(\hat\beta\) is bounded by \(\kappa\) times the relative error in \(y\). Belsley, Kuh & Welsch’s conventional thresholds: \(\kappa < 10\) negligible multicollinearity, \(10\)\(30\) moderate (coefficients interpretable but inflated SEs), \(\geq 30\) severe (coefficients unstable under small data perturbations). VIF is the per-feature counterpart of the same diagnostic — global vs. per-coefficient. The relationship: \(\kappa^2 \geq \max_j \text{VIF}_j\) for a standardised design, so a high VIF implies high \(\kappa\) but \(\kappa\) can also be inflated by interaction patterns VIF misses. Example: three standardised features whose covariance eigenvalues are 2.8, 0.95, 0.05 produce \(\kappa = \sqrt{2.8/0.05} \approx 7.5\) — moderate, no action. If the smallest eigenvalue were 0.01 instead, \(\kappa\) jumps to 17, and dropping a feature or applying ridge regression (which adds a small constant to the diagonal to bound \(\kappa\)) becomes appropriate.

6.2 Fitting the isolation forest

Show code
X = StandardScaler().fit_transform(X_raw.to_numpy())

iso = IsolationForest(
    n_estimators=400, contamination=0.01, random_state=42, n_jobs=-1
)
sub["iforest_score"]    = -iso.fit(X).score_samples(X)   # higher = more anomalous
sub["iforest_flag"]     = iso.predict(X) == -1

print(f"Rows analysed: {len(sub):,}")
Rows analysed: 506,907
Show code
print(f"Isolation forest flags: {int(sub['iforest_flag'].sum()):,} ({sub['iforest_flag'].mean()*100:.2f}%)")
Isolation forest flags: 5,069 (1.00%)
Show code
print("Overlap with robust-z ≥ 5:",
      int((sub['iforest_flag'] & (sub['robust_z'] >= 5)).sum()))
Overlap with robust-z ≥ 5: 255
Show code
fig, ax = plt.subplots(figsize=(7, 5))
_ = ax.scatter(sub["beneficiaries"], sub["paid_per_bene"],
               c=np.where(sub["iforest_flag"], "#be123c", "#8a909c"),
               s=10, alpha=0.5)
_ = ax.set(xscale="log", yscale="log",
           xlabel="Unique beneficiaries (log)", ylabel="Paid per beneficiary (log)")
_ = ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout()
plt.show()

Paid-per-beneficiary vs. beneficiary count for the top 20 HCPCS codes by spend. Isolation-forest flags in orange overlap substantially with — but not identically to — the robust-z rule.

7 Step 5 — where does the dollar go? A geographic atlas

In one sentence. A county-level interactive map across all 12 spending categories, shaded by where each county sits within the distribution for its category — colours are comparable inside a category but not across them.

Up to this point the analysis has answered “who bills atypically?” — useful for a Program Integrity team. A different audience (policy researchers, state Medicaid administrators, journalists) wants the inverted question: “which kinds of care cost the most, and where geographically does the money flow?” Answering that requires three additional pieces of data engineering that the prep.py script already produced:

  1. A HCPCS → category classifier that buckets every billing code into one of 12 spending categories (Behavioral Health, Dental, Dialysis & ESRD, Prescription Drugs, and so on). The classifier is a deterministic rule-based map; the code lives below for transparency.
  2. A Census 2020 ZIP → county crosswalk that translates every provider’s practice ZIP into the standard 5-digit county FIPS code, allowing county-level aggregation.
  3. A county × category aggregate with within-category ranking so that the colour scale reads consistently across the 12 panels: darker shades always mean “high spend relative to other counties in this same category.”

Two important interpretive caveats up front: (1) The 12 categories are mostly service-type groupings that the HCPCS code system itself organises by — not disease categories. HCPCS codes don’t carry diagnoses. Reading the atlas as “where Medicaid spends on Behavioral Health services” is supportable; reading it as “where Medicaid spends on depression” is not. (2) The within-category colour scale is relative — the darkest navy in the Dental panel and the darkest navy in the Behavioral Health panel both mean “this county is among the highest in its category,” but they are not directly comparable dollar levels across categories.

NoteWhy county, not HSA or ZIP3?

For a Medicaid cost atlas, counties are the defensible middle path — clean TIGER polygons, HUD/Census maintained crosswalks, and the unit most federal-agency publications (MACPAC, KFF, Urban Institute) use. HSAs (Dartmouth Atlas) are the gold standard for Medicare market analysis but were built from Medicare patient flows, so they’re slightly miscalibrated for Medicaid. ZIP3 is tempting because it’s one LEFT(zip, 3) away, but ZIP3 boundaries are USPS logistics geography — a high Modifiable-Areal-Unit-Problem exposure for a spatial-stats story.

Show code
county_atlas <- arrow::read_parquet(file.path(data_dir, "county_group_atlas.parquet"))
group_totals <- arrow::read_parquet(file.path(data_dir, "group_totals.parquet"))

top_categories <- group_totals |>
  arrange(desc(total_paid)) |>
  pull(disease_group)

# Recompute a within-category decile rank on paid-per-beneficiary. The parquet
# carries quintiles from prep.py; we override with 10 bins here so shading
# shows more granular within-category variation without re-running the
# DuckDB prep. For categories with < 100 billing counties some deciles will
# be tied — the lambda collapses those cleanly.
county_atlas <- county_atlas |>
  group_by(disease_group) |>
  mutate(decile_ppb = dplyr::ntile(paid_per_bene, 10)) |>
  ungroup()
Show code
group_totals |>
  mutate(category = fct_reorder(disease_group, total_paid)) |>
  ggplot(aes(category, total_paid)) +
  geom_col(fill = "#1f4f99") +
  coord_flip() +
  scale_y_continuous(labels = format_money) +
  labs(x = NULL, y = "Total paid (USD)")

Total national Medicaid paid by HCPCS-derived category, 2024. Ranked descending by total paid. ‘Other Medical Services’ captures 99000-series codes the classifier doesn’t yet disambiguate.
Show code
options(tigris_use_cache = TRUE)
counties_sf <- tigris::counties(year = 2020, cb = TRUE, resolution = "5m", progress_bar = FALSE) |>
  dplyr::select(GEOID, geometry) |>
  dplyr::filter(!stringr::str_detect(GEOID, "^(02|15|60|66|69|72|78)")) |>
  sf::st_transform(4326)

navies <- grDevices::colorRampPalette(c("#eef1f8", "#1f4f99", "#0f2747"))(10)
pal <- leaflet::colorFactor(navies, domain = factor(1:10))

atlas_sub <- county_atlas |>
  filter(disease_group %in% top_categories)

lmap <- leaflet::leaflet(width = "100%", height = "620px") |>
  leaflet::addProviderTiles(
    leaflet::providers$CartoDB.PositronNoLabels,
    options = leaflet::providerTileOptions(opacity = 0.75)
  ) |>
  leaflet::setView(lng = -96, lat = 38, zoom = 4) |>
  leaflet::addPolygons(
    data        = counties_sf,
    fillColor   = "#ece9e1",
    fillOpacity = 0.6,
    color       = "white",
    weight      = 0.3,
    smoothFactor = 0.5,
    options     = leaflet::pathOptions(interactive = FALSE)
  )

for (g in top_categories) {
  dat <- counties_sf |>
    dplyr::inner_join(
      atlas_sub |> dplyr::filter(disease_group == g),
      by = c("GEOID" = "county_fips")
    )
  lmap <- lmap |>
    leaflet::addPolygons(
      data         = dat,
      group        = g,
      fillColor    = ~pal(factor(decile_ppb)),
      fillOpacity  = 0.88,
      color        = "white",
      weight       = 0.3,
      smoothFactor = 0.5,
      highlightOptions = leaflet::highlightOptions(
        weight       = 2,
        color        = "#4a5160",
        fillOpacity  = 0.95,
        bringToFront = TRUE
      ),
      popup = ~paste0(
        "<b>", county_name, ", ", state, "</b><br/>",
        "<em>", disease_group, "</em><br/>",
        "Paid / beneficiary: $",
        format(round(paid_per_bene), big.mark = ","), "<br/>",
        "Total paid: $",
        format(round(total_paid / 1e3), big.mark = ","), "K<br/>",
        "Providers: ", providers_n, "<br/>",
        "Within-category decile: ", decile_ppb, " of 10"
      )
    )
}

categories_json <- jsonlite::toJSON(top_categories)
dropdown_js <- sprintf("
function(el, x) {
  var map = this;
  var categories = %s;
  var initial = categories[0];

  function showOnly(picked) {
    categories.forEach(function(g) {
      var fg = map.layerManager.getLayerGroup(g, false);
      if (!fg) return;
      if (g === picked) {
        if (!map.hasLayer(fg)) fg.addTo(map);
      } else {
        if (map.hasLayer(fg)) map.removeLayer(fg);
      }
    });
  }
  showOnly(initial);

  var ctrl = L.control({position: 'topright'});
  ctrl.onAdd = function() {
    var div = L.DomUtil.create('div', 'leaflet-bar disease-group-control');
    div.style.background = 'white';
    div.style.padding = '8px 10px';
    div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.2)';
    div.style.font = '12px/1.3 -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif';

    var label = document.createElement('label');
    label.textContent = 'Category';
    label.style.cssText = 'display:block;font-weight:600;margin-bottom:4px;color:#333;';

    var sel = document.createElement('select');
    sel.style.cssText = 'padding:4px 6px;font-size:12px;border:1px solid #ccc;border-radius:3px;background:white;min-width:240px;';
    categories.forEach(function(g) {
      var opt = document.createElement('option');
      opt.value = g;
      opt.textContent = g;
      if (g === initial) opt.selected = true;
      sel.appendChild(opt);
    });
    sel.addEventListener('change', function(e) { showOnly(e.target.value); });

    div.appendChild(label);
    div.appendChild(sel);
    L.DomEvent.disableClickPropagation(div);
    L.DomEvent.disableScrollPropagation(div);
    return div;
  };
  ctrl.addTo(map);
}
", categories_json)

lmap |>
  htmlwidgets::onRender(dropdown_js) |>
  leaflet::addLegend(
    position = "bottomleft",
    colors   = navies,
    labels   = c("1 (lowest)", "2", "3", "4", "5", "6", "7", "8", "9", "10 (highest)"),
    title    = "Decile of paid<br/>per beneficiary",
    opacity  = 0.85
  )

Interactive county-level cost atlas: Medicaid paid per beneficiary ranked into within-category deciles (darker navy = higher cost relative to other counties for that same category). Each panel is its own choropleth so coloring is comparable within category, not across categories. Use the top-right dropdown to switch across all 12 HCPCS spending categories. Alaska, Hawaii, and territories omitted for contiguous-US framing; grey counties have no Medicaid providers billing that category.

8 Step 6 — category explorer

In one sentence. Three companion views of the atlas — top 5 billing codes per category, a Gini concentration number per category, and a state hotspot table — that answer the three follow-up questions a policy reader always asks next.

The choropleth shows where spending in each category concentrates. The three views below answer three follow-up questions that policy audiences ask almost reflexively: (1) what specific billing codes are driving the spend inside each category? (2) how concentrated is that spend across counties — is it spread evenly across the country, or does it collapse onto a handful of counties? (3) which states sit at the top of the per-beneficiary spending ladder for each category? Each of the chunks below iterates over all 12 categories — no cherry-picking — so the full picture is visible in a single sweep.

HCPCS → category classifier (mirrors the DuckDB CASE in prep.py)
classify_hcpcs <- function(code) {
  num    <- suppressWarnings(as.integer(code))
  is_cpt <- !is.na(num) & nchar(code) == 5
  dplyr::case_when(
    stringr::str_starts(code, "J")                               ~ "Prescription Drugs & Biologics",
    stringr::str_starts(code, "D")                               ~ "Dental",
    stringr::str_starts(code, "H")                               ~ "Behavioral Health",
    stringr::str_starts(code, "E") |
      stringr::str_starts(code, "K") |
      stringr::str_starts(code, "L")                             ~ "Durable Medical Equipment & Supplies",
    stringr::str_detect(code, "^T(20|21|22|51)") |
      stringr::str_starts(code, "A04")                           ~ "Transportation",
    stringr::str_starts(code, "T")                               ~ "Long-Term Services & Supports",
    code %in% c("G0257","G0323","G0324","G0325","G0326","G0327") ~ "Dialysis & ESRD",
    is_cpt & num >= 90791 & num <= 90899                         ~ "Behavioral Health",
    is_cpt & num >= 90935 & num <= 90999                         ~ "Dialysis & ESRD",
    is_cpt & num >= 99201 & num <= 99499                         ~ "Evaluation & Management",
    is_cpt & num >= 10021 & num <= 69990                         ~ "Surgery & Outpatient Procedures",
    is_cpt & num >= 70010 & num <= 79999                         ~ "Radiology & Imaging",
    is_cpt & num >= 80047 & num <= 89398                         ~ "Laboratory & Diagnostics",
    TRUE                                                         ~ "Other Medical Services"
  )
}

hcpcs_by_category <- hcpcs_sum |>
  mutate(category = classify_hcpcs(hcpcs)) |>
  filter(category %in% top_categories)

8.1 What codes drive each category?

Show code
top_hcpcs_per_category <- hcpcs_by_category |>
  dplyr::group_by(category) |>
  dplyr::arrange(dplyr::desc(total_paid), .by_group = TRUE) |>
  dplyr::slice_head(n = 5) |>
  dplyr::ungroup() |>
  dplyr::mutate(
    category = factor(category, levels = top_categories),
    row_id   = dplyr::row_number()
  )

ggplot(top_hcpcs_per_category,
       aes(x = stats::reorder(paste(hcpcs, row_id), total_paid),
           y = total_paid)) +
  geom_col(fill = "#1f4f99") +
  geom_text(aes(label = format_money(total_paid)),
            hjust = -0.08, size = 3, color = "#4a5160") +
  facet_wrap(~ category, scales = "free", ncol = 3) +
  scale_x_discrete(labels = \(x) sub(" \\d+$", "", x)) +
  scale_y_continuous(labels = format_money,
                     expand = expansion(mult = c(0, 0.22))) +
  coord_flip() +
  labs(x = NULL, y = "Total paid (all billing providers)")

Top 5 HCPCS codes by total Medicaid paid within each category. The codes carrying the weight are very different per category — J-codes (specialty infusibles and injectables) dominate the drug bucket; Transportation collapses onto A0/T20-series non-emergency transport; LTSS is driven by T-series personal-care and case-management codes; E&M is dominated by the 99213/99214 office-visit pair; a single periodic-exam code (D0120) and a pair of restoration codes drive most of Dental.

8.2 How spatially concentrated is each category?

NoteWhy the Gini coefficient — and what does “Gini = 0.6” mean?

The Gini coefficient measures how unequally a quantity is distributed across units. Originally an income-inequality measure; here we apply it to “Medicaid paid per county within a category.”

  • Gini = 0: perfectly equal — every county has the same total paid in that category.
  • Gini = 1: one county has everything; the rest have nothing.
  • Gini ≈ 0.4–0.6: moderate concentration — typical of national health-spending categories.
  • Gini > 0.7: spend collapses onto a handful of counties — expected for sparse, high-acuity categories like Dialysis & ESRD.

Geometrically, Gini is twice the area between the Lorenz curve (cumulative share of paid plotted against cumulative share of counties, sorted by paid) and the diagonal of perfect equality. We pair Gini with the top-10-county share because Gini is a single number that hides the shape; the top-10 share answers “concretely, how much does the top 10 carry?” There is a sharper reason to keep both. Once every category in the universe is concentrated — here all twelve sit above roughly 0.84 — Gini compresses into a narrow band and stops discriminating between them. The top-10-county share spreads more widely (from about 15% to 40% across categories) and carries the signal Gini has saturated out. When a concentration metric is uniformly high, read the companion measure, not the headline number.

Use Gini for any concentration analysis: spending across counties, hospitalizations across providers, citations across papers, wealth across households. Don’t use it for symmetric-around-zero quantities (it’s defined on non-negative values). (Lorenz 1905; Gini 1912; Cowell 2011)

Sort \(n\) units ascending by value, then plot the cumulative fraction held by the bottom \(k\) units against the fraction \(k/n\). The resulting step curve is the Lorenz curve \(L(p)\). Under perfect equality everyone has the same value, so \(L(p) = p\) — the diagonal. Under perfect inequality one unit has everything, so the curve hugs the bottom axis until the very end. Real distributions live between. Gini is twice the area between the diagonal and the Lorenz curve: \[G = 1 - 2 \int_0^1 L(p)\, dp.\] Since the area under the diagonal is \(\tfrac{1}{2}\), this expression sits cleanly in \([0, 1]\): 0 when the Lorenz curve is the diagonal, 1 when it hugs the axis. Substituting the discrete Lorenz step formula and integrating gives the working formula \[G = \tfrac{2 \sum_i i \cdot x_{(i)}}{n \sum_i x_{(i)}} - \tfrac{n + 1}{n},\] where \(x_{(i)}\) are the sorted values. (Sanity check: with equal values \(x_{(i)} = c\), the first term equals \((n+1)/n\) and \(G = 0\).) Four counties with paid totals \((10, 20, 50, 200)\) give \(\sum i \cdot x_{(i)} = 1000\), \(\sum x = 280\), and \(G = 2000/(4 \cdot 280) - 5/4 \approx 0.54\) — substantial concentration, with the top county holding 71% of the spend. The Theil index \(T = \tfrac{1}{n}\sum (x_i/\bar{x})\log(x_i/\bar{x})\) is the natural alternative: entropy-based, decomposable into within- and between-group components in a way Gini isn’t, so it’s the right choice when you want to attribute concentration across subgroups.

Show code
gini <- function(x) {
  x <- sort(x[!is.na(x) & x >= 0])
  n <- length(x)
  if (n <= 1 || sum(x) == 0) return(NA_real_)
  2 * sum(seq_len(n) * x) / (n * sum(x)) - (n + 1) / n
}

spatial_conc <- county_atlas |>
  filter(disease_group %in% top_categories) |>
  group_by(category = disease_group) |>
  summarise(
    # NOTE on denominator: Gini and top-10 share are computed over the
    # subset of counties that have ANY billing in this category. Counties
    # with zero billing in this category are absent, not included as
    # zeros. This means the reported concentration is "concentration
    # among billing counties," not "concentration over all 3,100+ US
    # counties." For sparse categories (e.g., Dialysis & ESRD, billed
    # in ~500 counties), the *national* geographic concentration is
    # therefore higher than the Gini number alone suggests; the grey
    # baseline in the choropleth makes the absence visible to the eye.
    counties_n    = dplyr::n(),
    gini_paid     = gini(total_paid),
    top10_share   = sum(sort(total_paid, decreasing = TRUE)[seq_len(min(10, dplyr::n()))],
                        na.rm = TRUE) / sum(total_paid, na.rm = TRUE),
    .groups = "drop"
  ) |>
  arrange(desc(gini_paid))

spatial_conc |>
  mutate(category = fct_reorder(category, gini_paid)) |>
  ggplot(aes(category, gini_paid)) +
  geom_col(fill = "#1f4f99") +
  geom_text(aes(label = sprintf("Gini %.2f · top-10 share %s",
                                gini_paid,
                                scales::percent(top10_share, accuracy = 1))),
            hjust = -0.05, size = 3.1, color = "#4a5160") +
  coord_flip(clip = "off") +
  scale_y_continuous(limits = c(0, 1.05),
                     labels = scales::number_format(accuracy = 0.01),
                     expand = expansion(mult = c(0, 0))) +
  labs(x = NULL, y = "Gini of county Medicaid paid (within category)") +
  theme(plot.margin = margin(5, 110, 5, 5))

Gini coefficient of county Medicaid paid within each category, and the share of that category’s total spend captured by just the top 10 counties. A Gini near 1 means spend collapses onto a handful of counties — expected for sparse, high-acuity categories like Dialysis & ESRD; Gini near 0 is evenly diffused public outlay, like LTSS and E&M.

8.3 Which states lead each category?

Show code
state_hotspots <- county_atlas |>
  filter(disease_group %in% top_categories, !is.na(state)) |>
  group_by(state, category = disease_group) |>
  summarise(
    state_paid = sum(total_paid,  na.rm = TRUE),
    state_bene = sum(total_bene,  na.rm = TRUE),
    counties   = dplyr::n(),
    .groups = "drop"
  ) |>
  filter(state_bene >= 1000) |>
  mutate(state_ppb = state_paid / state_bene) |>
  group_by(category) |>
  arrange(desc(state_ppb), .by_group = TRUE) |>
  slice_head(n = 5) |>
  ungroup() |>
  mutate(category = factor(category, levels = top_categories)) |>
  arrange(category, desc(state_ppb))

pretty_kable(
  state_hotspots |>
    transmute(
      Category             = category,
      State                = state,
      Counties             = counties,
      `Total paid`         = format_money(state_paid),
      Beneficiaries        = format(state_bene, big.mark = ","),
      `$ per beneficiary`  = format_money(state_ppb)
    ),
  caption = "Top 5 states by Medicaid paid-per-beneficiary within each category (states with at least 1,000 beneficiaries in that category)."
)
Top 5 states by Medicaid paid-per-beneficiary within each category (states with at least 1,000 beneficiaries in that category).
Category State Counties Total paid Beneficiaries $ per beneficiary
Other Medical Services P.R. 1 $716K 1,634 $438
Other Medical Services VT 15 $1B 2,411,083 $426
Other Medical Services MT 51 $3B 7,585,014 $337
Other Medical Services ND 45 $600M 2,283,311 $263
Other Medical Services AK 29 $1B 4,940,328 $222
Long-Term Services & Supports VI 3 $4M 1,007 $4K
Long-Term Services & Supports NY 60 $81B 39,622,710 $2K
Long-Term Services & Supports MT 19 $664M 646,440 $1K
Long-Term Services & Supports NJ 21 $8B 8,670,989 $891
Long-Term Services & Supports MO 97 $11B 12,385,622 $866
Behavioral Health NJ 21 $14B 10,839,249 $1K
Behavioral Health AK 22 $1B 1,097,695 $996
Behavioral Health VA 110 $8B 10,608,834 $792
Behavioral Health IA 93 $4B 5,450,818 $705
Behavioral Health NC 100 $5B 7,250,452 $701
Evaluation & Management AK 30 $1B 4,993,491 $275
Evaluation & Management FLORIDA 1 $3M 11,448 $227
Evaluation & Management HI 5 $1B 6,402,592 $169
Evaluation & Management GU 1 $45M 303,958 $149
Evaluation & Management ND 47 $346M 2,638,137 $131
Transportation ME 16 $3B 1,416,556 $2K
Transportation ND 24 $742M 440,929 $2K
Transportation MO 103 $7B 4,391,794 $2K
Transportation KS 62 $3B 2,321,419 $1K
Transportation SD 35 $1B 918,455 $1K
Dental VI 4 $42M 232,406 $182
Dental AK 26 $280M 2,119,601 $132
Dental GU 1 $26M 279,137 $93
Dental DE 3 $280M 3,592,587 $78
Dental SD 49 $113M 1,586,139 $71
Laboratory & Diagnostics AK 24 $473M 4,901,611 $96
Laboratory & Diagnostics GU 1 $4M 59,705 $66
Laboratory & Diagnostics RI 6 $519M 9,632,664 $54
Laboratory & Diagnostics VT 15 $21M 499,232 $42
Laboratory & Diagnostics MO 111 $570M 13,833,053 $41
Radiology & Imaging AK 15 $151M 904,453 $167
Radiology & Imaging KY 97 $2B 14,414,233 $105
Radiology & Imaging NM 27 $505M 5,539,215 $91
Radiology & Imaging NH 10 $66M 739,396 $90
Radiology & Imaging RI 5 $112M 1,268,089 $89
Surgery & Outpatient Procedures GU 1 $2M 3,133 $487
Surgery & Outpatient Procedures DC 1 $74M 460,044 $161
Surgery & Outpatient Procedures WY 16 $7M 59,936 $123
Surgery & Outpatient Procedures AK 23 $57M 469,960 $122
Surgery & Outpatient Procedures OK 72 $116M 1,182,111 $98
Durable Medical Equipment & Supplies MN 46 $591M 2,572,195 $230
Durable Medical Equipment & Supplies PR 13 $130M 657,116 $198
Durable Medical Equipment & Supplies GU 1 $326K 2,414 $135
Durable Medical Equipment & Supplies WA 25 $283M 2,213,151 $128
Durable Medical Equipment & Supplies TX 96 $512M 4,542,466 $113
Prescription Drugs & Biologics PR 32 $308M 225,720 $1K
Prescription Drugs & Biologics RI 4 $119M 252,464 $470
Prescription Drugs & Biologics SD 24 $28M 128,148 $222
Prescription Drugs & Biologics IL 69 $98M 513,921 $190
Prescription Drugs & Biologics HI 4 $184M 1,195,052 $154
Dialysis & ESRD RI 4 $37M 19,441 $2K
Dialysis & ESRD FL 54 $973M 791,693 $1K
Dialysis & ESRD HI 4 $153M 127,743 $1K
Dialysis & ESRD NV 9 $188M 176,193 $1K
Dialysis & ESRD CA 44 $4B 3,503,843 $1K

9 Step 7 — state-level intensity

A final view rolls the per-provider “excess spend” all the way up to the state level. For each provider–code row, the prep step pre-computed excess_paid as the dollars billed above what the peer-group median would predict; summing that across all providers in a state gives a state-level “above-median excess” total. Read this chart with the caveat that “excess” here is just a deviation from the peer median, not a per-capita rate — large states will naturally have more total excess, so the ranking blends intensity (above-median per provider) with volume (number of providers in the state). A per-beneficiary-adjusted version would be a better intensity measure but isn’t computed here.

Show code
state_excess <- state_sum |>
  arrange(desc(excess_paid)) |>
  slice_head(n = 25)

ggplot(state_excess, aes(reorder(state, excess_paid), excess_paid)) +
  geom_col(fill = "#1f4f99") +
  coord_flip() +
  scale_y_continuous(labels = format_money) +
  labs(x = NULL, y = "Paid above HCPCS peer median ($)")

Peer-group-excess Medicaid spending by state. Height is the sum of paid dollars above each HCPCS’s peer median, aggregated at prep time across all providers billing in that state.

10 What we learned

The seven-step pipeline (DuckDB aggregate → robust z → NPPES enrichment → ZCTA-county join → category classifier → atlas → explorer) supports five conclusions that each tie back to a specific diagnostic on this page:

  • Heavy tails are the regime, not the exception. The paid-per-beneficiary distribution in eda-distribution is log-skewed across four orders of magnitude. That’s why a mean/SD z-score is unusable here: the SD is dominated by one or two mega-providers and every other observation looks “normal” by comparison. The MAD-based robust z is invariant to those tail rows, which is why the same threshold (≥ 5) generalizes across HCPCS codes with very different peer-group sizes.
  • Rule-based and unsupervised flags agree on a “highest-confidence-for-review” set. The overlap between robust-z ≥ 5 and the isolation-forest 1%-contamination flag is the set a Program Integrity team would prioritise for human review — not a list of confirmed bad actors. Without an LEIE / sanctioned-providers cross-check, the precision of this surfacing is unmeasured. Disagreement between the two methods is the interesting set — where a tail-only metric (robust z) and a joint-distribution metric (isolation forest) see different things, usually worth a second human look.
  • Collinearity didn’t break the isolation forest, but it constrained tree diversity. The condition number on the four standardized features lands above the rule-of-thumb threshold because claims and beneficiaries are nearly identical by construction. We kept both for interpretability — but the cost is that some trees in the ensemble split on essentially the same axis. A leaner three-feature variant (drop claims) would produce a slightly more diverse forest at the cost of a slightly less interpretable feature set; for a fraud-review prompt list, that tradeoff goes the way we chose.
  • Categories are an HCPCS lens, not a disease lens. The 12-bucket atlas is honest about what it is: a procedure-code taxonomy projected onto county geography, not a disease taxonomy. Reading the choropleth for “where Medicaid spends money on Behavioral Health procedures” is supportable; reading it for “where Medicaid pays for treating depression” is not — depression doesn’t have a HCPCS code, only the services delivered for depression do. Claims data more broadly runs on several code systems, each licensing different claims: HCPCS/CPT for procedures and services, ICD for diagnoses, NDC for specific drug products, LOINC for laboratory tests, and SNOMED CT for clinical findings and concepts. Knowing which system a field is coded in, and what that system can and cannot assert, is half of real-world-data competence. The Gini concentration plot is a useful tie-breaker: categories with high Gini (Dialysis & ESRD, parts of DME) genuinely cluster geographically; categories with low Gini (LTSS, E&M) are diffuse across the country and shouldn’t be read as having geographic “hotspots” at all.
  • Geography is the start of an interpretation, never the end. State-level Medicaid rate schedules, MCO contracting differences, and referral-network composition can all produce county-level patterns that look like billing outliers but are artifacts of policy. The state hotspot tables in the explorer make those patterns visible; deciding which are actionable requires a payer-policy lens this notebook does not pretend to provide.

11 Caveats and what would change the picture

  • Single year, no temporal control. The dataset is the most recent monthly drop; nothing here separates persistent outliers from transient spikes (COVID-era catch-up billing, one-off audits, policy shocks). Rolling 3-month windows would.
  • Provider geocoding gaps shape the map. The geo-coverage diagnostic in EDA shows the share of outlier rows missing a state or ZCTA centroid; counties whose providers have unresolved addresses are under-represented on the choropleth, not just absent. The grey baseline layer makes “no data” visible but doesn’t distinguish “no providers billing this category” from “providers exist but didn’t geocode.” The gaps are not random. Addresses fail to resolve for structural reasons — P.O. boxes and administrative billing addresses, telehealth-only providers whose registered address need not sit where their patients are, U.S. territories outside the Census centroid file, and rural ZIPs that straddle county lines — so the providers that drop off the map cluster toward telehealth, territories, and rural geographies rather than scattering evenly. The fraction missing is a biased subset, not a random sample, and the bias runs in a direction that matters for a geographic story.
  • HCPCS coverage is sparse for some categories. Dialysis & ESRD is billed in roughly 500 counties; the within-category decile shading there is meaningful but bins are sparse, so the visual difference between deciles 4 and 5 is less stable than for densely-covered categories like E&M.

12 Next steps

  • Specialty-adjusted peer groups. Today the peer group is “everyone billing this HCPCS”; tighter peer groups (same specialty, similar patient panel size) will surface a different, higher-quality signal — especially in categories where the peer pool mixes radically different practice types.
  • Time-aware outlier detection. Repeat the analysis on rolling 3-month windows to separate persistent outliers from transient spikes; the same robust-z framework applies, the only change is the partitioning column in the DuckDB prep.
  • SDoH overlay. Merge state-level CDC PLACES / ACS indicators to test whether residual excess spending tracks community need vs. billing behavior — a health-equity stress test for the flagged list. The county-FIPS join already exists; adding a tract-level layer is a one-join extension.

13 Methods primer

Every statistical test, estimator, or transformation used above, with a one-line definition, what it does here, the key assumption, what to do if that assumption fails, and the canonical primary source. Full citations are in the References section that follows.

Method What it does Why used here Key assumption If assumption fails Reference
Median, MAD, robust z Median = middle value (robust to outliers); MAD = median absolute deviation; robust z = (x − median) / (1.4826 × MAD), interpretable as approximate SDs from the median under normality. Per-HCPCS peer-comparison flagging — heavy-tailed claims data make mean/SD z’s worthless. Errors roughly symmetric around the median; 1.4826 calibration exact only for normal data. Use bootstrap-derived peer percentiles. Hampel 1974; Rousseeuw & Croux 1993
Variance-to-mean ratio (VMR) The ratio var(x) / mean(x). For Poisson VMR = 1; values above 1 indicate overdispersion. Diagnose whether count-like features need NB or ZINB rather than Poisson in any future modeling. Variable is non-negative count-like. None — descriptive. Cameron & Trivedi 2013
Zero-inflation diagnostic Fraction of observations equal to zero, paired with VMR. Flag when a count distribution has more zeros than NB can produce → use zero-inflated models. Zeros are meaningfully distinct from small positive counts. Use a hurdle model. Lambert 1992; Mullahy 1986
Pearson correlation Linear association between two continuous variables; r ∈ [−1, +1]. Detect collinear features before fitting the isolation forest (claims vs beneficiaries are nearly the same construct). Linear relationship; no extreme outliers. Use Spearman rank correlation. Pearson 1896
Condition number (κ) Ratio of largest to smallest singular value of the standardized design matrix. κ > 30 = problematic collinearity. Single-number summary of multicollinearity across all features at once. Standardized features (mean 0, var 1). Drop redundant features or use ridge. Belsley, Kuh & Welsch 1980
Variance Inflation Factor (VIF) For each feature, regress it on the others; VIF = 1 / (1 − R²). Per-feature collinearity diagnostic for the isolation-forest input set. Linear regression for the auxiliary fit. Drop the redundant feature. Marquardt 1970; Belsley, Kuh & Welsch 1980
Two-sided p-value from a normal approximation Convert a z-score to a probability by 2 × Φ(−\|z\|). Step needed before applying BH-FDR — turn each robust z into a tail probability. Null distribution of robust z approximately normal. Use a permutation-derived empirical null. Standard normal-tail formula
Benjamini–Hochberg FDR (p.adjust(method = "BH")) Adjusts a vector of p-values to control the false discovery rate — the expected fraction of false positives among rejections. Required when testing thousands of NPI × HCPCS rows simultaneously. Independent or positively-dependent tests. Use Benjamini–Yekutieli for arbitrary dependence. Benjamini & Hochberg 1995
Benjamini–Yekutieli FDR FDR correction valid under arbitrary dependence between tests. Cited as the fallback when BH’s positive-dependence assumption is unsafe. None on dependence structure; trades power for safety. N/A Benjamini & Yekutieli 2001
Mann-Whitney U / Wilcoxon rank-sum Non-parametric test that one distribution is shifted relative to another. Operates on ranks. Compare beneficiary counts and paid amounts between high-z and moderate-z subsets. Independent observations; no distributional assumption. Permutation test. Wilcoxon 1945; Mann & Whitney 1947
Pearson chi-squared Tests independence between two categorical variables. Compare specialty mix and state distribution between flagged subsets. Expected cell counts ≥ 5 in most cells. Use Fisher’s exact test. Pearson 1900
Isolation forest Unsupervised anomaly detector that builds many random binary trees and scores points by how easily they get isolated (shorter average path = more anomalous). Second-opinion flagging on the joint distribution of features. Anomalies are “few and different.” Use one-class SVM, LOF, or an autoencoder. Liu, Ting & Zhou 2008, 2012
Standardization (StandardScaler) Centers each feature at zero and scales to unit variance. Required before isolation forest splits compare across features at the same scale. Features are roughly continuous. Use rank-based standardization for heavy-tailed features. Standard preprocessing
NTILE / decile binning (dplyr::ntile) Splits a vector into n equal-sized rank bins. Within-category ranking for the choropleth — coloring comparable inside a category, not across. Continuous variable with few ties. Use cut() on quantiles to allow unequal bins. Conover 1999
Gini coefficient Twice the area between the Lorenz curve and the diagonal of perfect equality. 0 = uniform; 1 = one unit has everything. Quantify spatial concentration of spend within each Medicaid category. Non-negative quantity; no missing values. Report top-K share or Theil index. Gini 1912; Cowell 2011
Lorenz curve Cumulative share of the quantity plotted against cumulative share of units (sorted ascending). Geometric basis for the Gini coefficient. Non-negative quantity. Use a CDF plot. Lorenz 1905
Top-K share Sum of the K largest values divided by the total. Companion to Gini — “the top 10 counties carry X% of the spend.” Ordering by the same metric being summed. None — fully transparent. Standard descriptive statistic
patchwork::wrap_plots Composes multiple ggplot objects into one figure with shared layout. Pack four diagnostic histograms into a single 2×2 panel. All inputs are ggplot objects. Use gridExtra::grid.arrange or base layouts. Pedersen 2024
Provider taxonomy joins (NPPES + NUCC + ZCTA → county) Sequence of left joins to enrich each NPI with state, specialty, and county-FIPS via NPPES → NUCC taxonomy and Census ZCTA crosswalks. Without this enrichment, flagged outliers are just NPIs; with it they’re “an internist in Hennepin County, MN.” Crosswalk tables match correctly on join keys. Report the % of rows that fail the join (geo-coverage diagnostic). CMS NPPES; NUCC 2025; US Census ZCTA

14 References

Statistical methods (chronological by topic):

  • Lorenz, M. O. (1905). Methods of measuring the concentration of wealth. Publications of the American Statistical Association, 9(70), 209–219.
  • Gini, C. (1912). Variabilità e mutabilità. Tipografia di Paolo Cuppini, Bologna.
  • Pearson, K. (1896). Mathematical contributions to the theory of evolution. III. Regression, heredity, and panmixia. Philosophical Transactions of the Royal Society A, 187, 253–318.
  • Pearson, K. (1900). On the criterion that a given system of deviations from the probable in the case of a correlated system of variables is such that it can be reasonably supposed to have arisen from random sampling. Philosophical Magazine, 50(302), 157–175.
  • Wilcoxon, F. (1945). Individual comparisons by ranking methods. Biometrics Bulletin, 1(6), 80–83.
  • Mann, H. B., & Whitney, D. R. (1947). On a test of whether one of two random variables is stochastically larger than the other. Annals of Mathematical Statistics, 18(1), 50–60.
  • Marquardt, D. W. (1970). Generalized inverses, ridge regression, biased linear estimation, and nonlinear estimation. Technometrics, 12(3), 591–612.
  • Hampel, F. R. (1974). The influence curve and its role in robust estimation. Journal of the American Statistical Association, 69(346), 383–393.
  • Belsley, D. A., Kuh, E., & Welsch, R. E. (1980). Regression Diagnostics: Identifying Influential Data and Sources of Collinearity. Wiley.
  • Mullahy, J. (1986). Specification and testing of some modified count data models. Journal of Econometrics, 33(3), 341–365.
  • Lambert, D. (1992). Zero-inflated Poisson regression, with an application to defects in manufacturing. Technometrics, 34(1), 1–14.
  • Rousseeuw, P. J., & Croux, C. (1993). Alternatives to the median absolute deviation. Journal of the American Statistical Association, 88(424), 1273–1283.
  • Benjamini, Y., & Hochberg, Y. (1995). Controlling the false discovery rate: a practical and powerful approach to multiple testing. Journal of the Royal Statistical Society, Series B, 57(1), 289–300.
  • Conover, W. J. (1999). Practical Nonparametric Statistics (3rd ed.). Wiley.
  • Benjamini, Y., & Yekutieli, D. (2001). The control of the false discovery rate in multiple testing under dependency. Annals of Statistics, 29(4), 1165–1188.
  • Liu, F. T., Ting, K. M., & Zhou, Z.-H. (2008). Isolation forest. In Eighth IEEE International Conference on Data Mining (pp. 413–422).
  • Liu, F. T., Ting, K. M., & Zhou, Z.-H. (2012). Isolation-based anomaly detection. ACM Transactions on Knowledge Discovery from Data, 6(1), Article 3.
  • Cowell, F. A. (2011). Measuring Inequality (3rd ed.). Oxford University Press.
  • Cameron, A. C., & Trivedi, P. K. (2013). Regression Analysis of Count Data (2nd ed.). Cambridge University Press.
  • Pedersen, T. L. (2024). patchwork: The Composer of Plots (R package). https://patchwork.data-imaginist.com

Data sources:


TipReading this as code samples

Every cell above is the real executed code — not screenshots. Use the Code dropdown at the top-right to toggle visibility, copy blocks, or view the raw .qmd source on GitHub. The one-shot DuckDB prep step lives in prep.py.

Note

Learn the methods. The From Data to Bedside pathway → walks the theory behind analyses like this one, rung by rung.