Skip to main content

Audit any risk-stratification model for label-choice bias — how much real need it leaves behind

Project description

risk-equity-audit

Auditing label-choice bias in healthcare risk-stratification models.

riskaudit is a Python library that audits any risk-stratification model for label-choice bias — how much real "need" a model leaves behind when it is trained on a convenient proxy (usually healthcare spending) instead of need itself. It works on any model's output: give it the scores a model assigned, an independent need measure, and population weights.

CI license DOI

Top-decile capture of measured need on MEPS: oracle 41 percent, need-trained model 29 percent, spend-trained model 15 percent, random floor 10 percent.

riskaudit's own output on the MEPS example: a deployed spend-trained model captures barely more measured need than a random score — and less than half of what ranking by need would.

(Versión en español más abajo — ir al español.)


Install

pip install riskaudit

Python ≥ 3.11. The core is light (numpy, pandas, scikit-learn, matplotlib); the MEPS example's heavier stack is an extra: pip install "riskaudit[meps]".

Quickstart

Audit your own model's scores in a few lines — no training, no PHI:

import numpy as np
from riskaudit.audit import top_k_capture

rng = np.random.default_rng(2026)
scores  = rng.random(5_000)   # what your risk model output per person
need    = rng.random(5_000)   # an independent measure of real need
weights = rng.random(5_000)   # population / survey weights

c = top_k_capture(scores, need, k=0.10, weights=weights)
print(f"top-decile capture: {c.value:.0%}  (floor {c.baseline:.0%}, oracle {c.oracle:.0%})")
# top-decile capture: 10%  (floor 10%, oracle 19%)   <- a random score sits at the floor

Every result carries a confidence interval (row or design-based) and reads against its floor (a random score) and oracle (ranking by need itself) — a capture number is meaningless without them. audit_report(results, "audit.html") bundles the whole set into a self-contained HTML report.

What this is

Health systems have limited budgets for expensive programs, so a model decides who gets prioritized — usually by predicting who will "spend the most," because spending is recorded for everyone. That choice has a blind spot: someone in psychological distress who does not seek care has near-zero spending, so a spend-trained model calls them low risk and never looks. The need was there; the label never looked for it.

This is label-choice bias — an algorithm trained on cost as a proxy for need under-serves people whose need hasn't turned into spending. riskaudit measures it, on any model, without retraining.

The toolkit

riskaudit does not train models or make predictions. It is an auditor: give it scores and an independent need measure, and it quantifies how much need the model leaves behind — weighted, with a row or design-based (VARSTR/PSU) confidence interval. Because it works purely on scores + need + weights, it is domain- and country-agnostic: the same functions audit a hospital's readmission model, an insurer's cost model, or a ministry's triage algorithm.

Function Question it answers
top_k_capture(scores, need, k, weights) Of all real need, what fraction lands in the top-k the model prioritizes — vs. the random floor and the oracle ceiling?
reclassification(scores_a, scores_b, k, weights) If we switch the label from A to B, who enters and leaves the priority list?
label_choice_curve(scores, need, weights, bins) Where do the highest-need people sit on the score?
ablation(fit_fn, X, y, feature_groups, need, k, weights) Cross-fitted: how much does global performance drop vs. how much does capture of need collapse when a feature group is removed?
incremental_lift(y_t1, y_pred, distress, scores, k, weights) The contribution metric: among the deprioritized, do those in need generate more future outcome than predicted? Makes the argument non-circular.
regression_to_mean(y_t, y_t1, scores_t, k, weights) How much of a top-k outcome drop is just regression to the mean? (descriptive)
label_robustness(scores, need, weights, k) How wrong the need label would have to be to explain away the gap.
audit_report(results, out_html) Bundles everything into a self-contained HTML report.

The MEPS example

A worked example on U.S. MEPS 2021–2023 shows the tool finds real bias — a demonstration, not the point. The figure at the top is its headline; the curve below shows where the highest-need people land on each model's score.

Label-choice curve: the need-trained model's mean need rises steeply with its score percentile; the spend-trained model's is much shallower.

Findings (weighted, design-based CIs): the spend model captures only ~15% of top-decile K6 need — barely above the ~10% random floor, far below the ~41% oracle; a need-trained model reaches ~29%. Among the people the spend model deprioritizes, those in distress run up more total future cost than it predicted (incremental lift +0.8 log-dollars with the mental-health features, +1.0 without — both 95% CIs exclude zero): the bias comes from the label, not from missing information.

Honest limit. That excess does not appear on non-psychiatric utilization (ER + hospitalizations; lift ≈ 0, CI includes zero), so I do not claim the distress surfaces specifically as non-psychiatric spending — the total-spend excess may be partly mental-health spending, and a clean test needs a non-psychiatric spend target (backlog). The severe-untreated subgroup (n ≈ 40) is descriptive only. The figures regenerate from the pipeline with python docs/make_figures.py.

Data sources

No data is stored in this repository. Only download scripts and checksums are versioned; everything under data/ is git-ignored.

  • MEPS (AHRQ, U.S.): HC-233/243/251 (FYC 2021–2023), HC-244 (Panel 26 longitudinal), HC-231 (Conditions) and HC-229A (Prescribed Medicines) for the treatment proxy — free, no registration, downloaded with SHA-256 checksums. See PROTOCOL.md §3.
  • Synthetic (demo): generated in-script by demo/run_demo.py — no external data, no real patients.

Limitations

Honesty about limits is a feature of this project, not a footnote:

  • The severe untreated subgroup is small (~40 in the analytic sample) — reported descriptively with wide CIs, never modeled. The robust finding is the population-level mechanism, not an anecdote about invisible patients.
  • "Need" is a normative choice. Calling a model "biased" asserts which target is legitimate need; that judgment is stated and defended in docs/methods.md, not assumed. label_robustness stress-tests it.
  • Survey design is respected throughout — sample weights and a stratified cluster bootstrap over VARSTR/VARPSU, never unweighted or naïvely filtered.

Roadmap

v0.1.x released (on PyPI, archived on Zenodo). Next (see docs/roadmap-v2.md): validation against Obermeyer et al. 2019 on their public synthetic data, and API expansion — subgroup capture, the label-blend decision frontier, cost-of-blindness, and stability of the priority list. Closed list, one function at a time.

How to cite

Proromant, C. (2026). riskaudit: auditing label-choice bias in healthcare risk-stratification models [Software]. Zenodo. https://doi.org/10.5281/zenodo.21461268

Machine-readable metadata is in CITATION.cff.

License

MIT © Conrado — MD (PUC), MSc(c) Data Science (PUC).



risk-equity-audit (español)

Auditoría del sesgo por elección de etiqueta en modelos de estratificación de riesgo en salud.

riskaudit es una librería en Python que audita cualquier modelo de estratificación de riesgo en busca de sesgo por elección de la etiqueta — cuánta "necesidad" real deja fuera un modelo cuando se entrena con un proxy cómodo (típicamente el gasto sanitario) en lugar de la necesidad misma. Trabaja sobre la salida de cualquier modelo: le das los scores que asignó, una medida independiente de need (necesidad) y weights poblacionales.

Captura de necesidad medida en el top-decil (MEPS): oráculo 41 por ciento, modelo de necesidad 29 por ciento, modelo de gasto 15 por ciento, piso al azar 10 por ciento.

Salida de la propia riskaudit sobre el ejemplo MEPS: un modelo desplegado entrenado con gasto captura la necesidad apenas por encima del azar — y menos de la mitad de lo que capturaría rankeando por necesidad.

Instalación

pip install riskaudit

Python ≥ 3.11. El core es liviano; para el ejemplo MEPS: pip install "riskaudit[meps]".

Quickstart

Audita los puntajes de tu propio modelo en pocas líneas — sin entrenar, sin datos sensibles:

import numpy as np
from riskaudit.audit import top_k_capture

rng = np.random.default_rng(2026)
scores, need, weights = rng.random(5_000), rng.random(5_000), rng.random(5_000)
c = top_k_capture(scores, need, k=0.10, weights=weights)
print(f"captura top-decil: {c.value:.0%}  (piso {c.baseline:.0%}, oráculo {c.oracle:.0%})")

Cada resultado trae IC (de filas o de diseño) y se lee contra su piso (score al azar) y su oráculo (rankear por la propia necesidad) — un número de captura no significa nada sin ellos. audit_report(results, "audit.html") empaqueta todo en un informe HTML autocontenido.

Qué es esto

Los sistemas de salud tienen presupuesto limitado, así que un modelo decide a quién se prioriza — normalmente prediciendo quién "va a gastar más", porque el gasto está registrado para todos. Esa elección tiene un punto ciego: alguien con distrés que no consulta tiene gasto casi cero, así que un modelo entrenado con gasto lo etiqueta como bajo riesgo y nunca lo mira. La necesidad estaba; la etiqueta nunca la buscó.

Esto es el sesgo por elección de la etiqueta: un algoritmo entrenado con el costo como proxy de necesidad sub-atiende a quienes su necesidad aún no se tradujo en gasto. riskaudit lo mide, sobre cualquier modelo, sin reentrenar.

La herramienta

riskaudit no entrena modelos ni predice. Es un auditor: le das scores y una medida independiente de necesidad, y cuantifica cuánta necesidad el modelo deja fuera — ponderado, con IC de filas o de diseño (VARSTR/PSU). Como trabaja solo con scores + need + weights, es agnóstico al dominio y al país: las mismas funciones auditan el modelo de reingresos de un hospital, el de gasto de una aseguradora o el algoritmo de priorización de un ministerio.

Función Pregunta que responde
top_k_capture De toda la necesidad real, ¿qué fracción cae en el top-k — vs. el piso al azar y el oráculo?
reclassification Si cambio la etiqueta de A a B, ¿quién entra y sale de la lista de prioridad?
label_choice_curve ¿Dónde caen las personas de mayor necesidad en el score?
ablation Cross-fitted: ¿cuánto baja el desempeño global vs. cuánto se desploma la captura de necesidad al quitar un grupo de features?
incremental_lift La métrica-contribución: entre los deprioritizados, ¿los que tienen necesidad generan más desenlace futuro del predicho? Hace no-circular el argumento.
regression_to_mean ¿Cuánto de una caída del top-k es solo regresión a la media? (descriptivo)
label_robustness ¿Cuán equivocada tendría que estar la etiqueta need para explicar la brecha?
audit_report Empaqueta todo en un informe HTML autocontenido.

El ejemplo MEPS

Un ejemplo sobre MEPS 2021–2023 (EE.UU.) muestra que la herramienta encuentra sesgo real — una demostración, no el objetivo. La figura de arriba es el titular; la curva de abajo muestra dónde caen las personas de mayor necesidad en el score de cada modelo.

Curva label-choice: la necesidad media del modelo de necesidad sube empinada con su percentil de score; la del modelo de gasto es mucho más superficial.

Hallazgos (ponderado, IC de diseño): el modelo de gasto captura solo ~15% de la necesidad K6 del top-decil — apenas sobre el ~10% del azar, muy por debajo del ~41% del oráculo; un modelo de K6 llega a ~29%. Entre los deprioritizados por el modelo de gasto, los que están en distrés acumulan más gasto total futuro del predicho (lift +0.8 log-dólares con las features de salud mental, +1.0 sin ellas — ambos IC 95% excluyen el cero): el sesgo viene de la etiqueta, no de falta de información.

Límite honesto. Ese exceso no aparece en la utilización no-psiquiátrica (urgencias + hospitalizaciones; lift ≈ 0, IC incluye el cero), así que no afirmo que el distrés se manifieste específicamente como gasto no psiquiátrico — un test limpio necesita un target de gasto no-psiquiátrico (backlog). El subgrupo severo no tratado (n ≈ 40) es solo descriptivo.

Limitaciones

La honestidad sobre los límites es parte del proyecto, no una nota al pie:

  • El subgrupo severo no tratado es pequeño (~40 en la muestra analítica): descriptivo, con IC anchos, nunca se modela. El hallazgo robusto es el mecanismo poblacional, no la anécdota de "los invisibles".
  • "Necesidad" es una elección normativa — se declara y defiende en docs/methods.md; label_robustness la somete a estrés.
  • El diseño muestral se respeta siempre: pesos y bootstrap de clúster estratificado sobre VARSTR/VARPSU, nunca sin ponderar.

Licencia

MIT © Conrado — Médico (PUC), MSc(c) Data Science (PUC).

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

riskaudit-0.1.2.tar.gz (200.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

riskaudit-0.1.2-py3-none-any.whl (32.0 kB view details)

Uploaded Python 3

File details

Details for the file riskaudit-0.1.2.tar.gz.

File metadata

  • Download URL: riskaudit-0.1.2.tar.gz
  • Upload date:
  • Size: 200.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for riskaudit-0.1.2.tar.gz
Algorithm Hash digest
SHA256 d861bd54111458c9e8d5e0dffbe762a25f5e8901bb0455006765c314483e5235
MD5 2a194607cfe844d38db181ccb52b653e
BLAKE2b-256 bfe9c8c8b23e0a3dd9d1ee3245e7f3e120149562da0894a8e07a32090a77a981

See more details on using hashes here.

File details

Details for the file riskaudit-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: riskaudit-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 32.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for riskaudit-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 21e24cc6579304cbb18383bca0ab0a79e4f6c4f86d635b6342abd0a1d694de99
MD5 de3be8320f624f331c3c8a42420699ef
BLAKE2b-256 f8451d10929a5d5f4b495a7866a03e9acefae1ca9bb50534203b8c11cddbb387

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page