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 — and the mental-health blind spot it creates.
riskaudit is a Python toolkit that audits any risk-stratification model for label-choice bias: the systematic error that appears when a model is trained on the wrong proxy for "need" (typically healthcare spending) instead of need itself. The tool is the product; a reproducible worked example on U.S. MEPS data shows it finds real bias.
Project status.
v0.1.0released and archived on Zenodo (DOI above). Theriskauditauditing library is implemented and tested (7 functions, design-based CIs, ~99% coverage); the MEPS case study and a self-contained demo run end to end. Built phase by phase perPROTOCOL.mdandPLAN.md.
(Versión en español más abajo — ir al español.)
What this is
Health systems have limited budgets for expensive programs — case management, home visits, close follow-up — so a model decides who gets prioritized, usually by predicting who is "high risk." The catch is how you define high risk. Most deployed models define it as "who will spend the most," because spending is recorded for everyone.
That choice has a blind spot. Consider someone in psychological distress who does not seek care: their observed spending is near zero, so a spend-trained model labels them low risk and ignores them — and the concern is that untreated distress may surface next year as other medical need (ER visits, worsened physical illness). The need was there; the label never looked for it.
This is label-choice bias: a well-documented failure where an algorithm trained on cost as a proxy for need under-serves people whose need hasn't yet turned into spending. This project applies that lens to untreated mental-health need and packages the audit as a reusable tool.
The claim in one sentence: a risk model trained on healthcare spending captures barely more measured need than a coin flip and systematically under-serves people in psychological distress — because the label (spending), not the algorithm, decides who counts as high risk. This project measures that label-choice cost on MEPS.
What's in the box
riskaudit— an installable Python package that audits label-choice bias in any risk-stratification model. This is the product: a reusable tool that does not depend on any particular dataset.- A worked example on MEPS 2021–2023 — a reproducible pipeline (three targets, identical models, full audit) that shows the tool finds real bias on U.S. survey data. It is a demonstration, not the point.
- End-to-end demo — a self-contained synthetic run (
demo/run_demo.py), with no credentials and no PHI, so anyone can see the tool working in seconds.
The riskaudit toolkit
riskaudit does not train models or make predictions. It is an auditor: give it the scores a model assigned and an independent measure of real need, and it quantifies how much need the model leaves behind — with population weights and confidence intervals (a plain row bootstrap, or a design-based one over survey strata/PSUs).
Because it works purely on scores and a need measure, riskaudit 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. The MEPS mental-health study below is one worked example showing the tool finds real bias — an illustration, not the tool's scope.
| Function | Question it answers |
|---|---|
top_k_capture(scores, need, k, weights) |
Of all real need in the population, what fraction lands in the top-k the model prioritizes? |
reclassification(scores_a, scores_b, k, weights) |
If we switch the label from A to B, who enters and leaves the priority list? |
ablation(fit_fn, X, y, feature_groups, k, weights) |
If we remove a feature group and retrain, how much does global performance drop vs. how much does capture of that group collapse? |
label_choice_curve(scores, need, weights, bins) |
Curve of score percentile vs. observed mean need — where does high need sit on the score? |
regression_to_mean(y_t, y_t1, scores_t, k, weights) |
How much of the top-k spending drop from year t to t+1 is just regression to the mean? |
incremental_lift(y_t1, y_pred, distress, scores, k, weights) |
The contribution metric: among people the model deprioritizes, do those with measured need generate more future outcome than predicted than the rest? Makes the argument non-circular. |
audit_report(results, out_html) |
Bundles everything into a self-contained HTML report. |
What the audit finds on MEPS (weighted, with design-based confidence intervals): the spend-trained model captures only ~15% of top-decile K6 need — barely above the ~10% a random score gets, and far below the ~41% an oracle (ranking by need itself) reaches; 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), so 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 we 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.
Install
Requires Python ≥ 3.11. Until the first release is published, install from source:
git clone https://github.com/<user>/risk-equity-audit.git
cd risk-equity-audit
pip install -e ".[dev]"
Quickstart
Minimal end-to-end audit on your own model's scores:
import numpy as np
from riskaudit.audit import top_k_capture, label_choice_curve, audit_report
rng = np.random.default_rng(2026)
n = 5_000
scores = rng.random(n) # what your risk model output per person
need = rng.random(n) # an independent measure of real need
weights = rng.random(n) # survey / population weights
capture = top_k_capture(scores, need, k=0.10, weights=weights)
curve = label_choice_curve(scores, need, weights=weights, bins=20)
print(f"Top-decile capture of need: {capture.value:.1%}")
audit_report({"capture": capture, "curve": curve}, out_html="audit.html")
The audit API above runs today, and a self-contained synthetic demo shows the whole flow end to end:
python demo/run_demo.py # no real data, no PHI, a few seconds, writes demo_report.html
Data sources
No data is stored in this repository. Only download scripts and checksums are versioned; everything under data/ is git-ignored.
- Core — MEPS (AHRQ, U.S.): HC-233 (2021), HC-243 (2022), HC-251 (2023), HC-244 (Panel 26 Longitudinal 2021–2022), plus HC-231 (2021 Conditions) and HC-229A (2021 Prescribed Medicines) for the mental-health treatment proxy — free, no registration; downloaded with SHA-256 checksums. See
PROTOCOL.md§3 for exact files, weights, and codebook references. - 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 (~135 people have severe distress, K6 ≥ 13, in both panel years; the untreated subset is smaller still). It is reported descriptively, with wide confidence intervals — 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" requires asserting which target is the legitimate measure of need. That judgment is stated and defended in
docs/methods.md, not assumed. - Survey design is respected throughout — final results always use sample weights and design-based variance (a stratified cluster bootstrap over VARSTR/VARPSU, with subpopulation/domain estimation), never unweighted or naïvely filtered.
Roadmap · Hoja de ruta
Built phase by phase (see PLAN.md for task-level detail and acceptance criteria):
- Phase 0 ✅ — Scaffolding: package layout, CI, tooling.
- Phase 1 ✅ — MEPS ETL: verified data dictionary, cleaned panel (6,741 persons).
- Phase 2 ✅ — Models + full weighted, design-based audit on MEPS.
- Phase 3 ✅ —
riskaudit.auditAPI (~99% coverage) + self-contained synthetic demo. - Phase 4 ✅ — Release
v0.1.0, archived on Zenodo (10.5281/zenodo.21461268).
How to cite
If you use riskaudit, please cite it:
Proromant, C. (2026). riskaudit: auditing label-choice bias in healthcare risk-stratification models (v0.1.0) [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 — y el punto ciego de salud mental que produce.
riskaudit es una herramienta en Python que audita cualquier modelo de estratificación de riesgo en busca de sesgo por elección de la etiqueta (label-choice bias): el error sistemático que aparece cuando el modelo se entrena con un proxy equivocado de "necesidad" (típicamente el gasto sanitario) en lugar de la necesidad misma. La herramienta es el producto; un ejemplo reproducible sobre datos de MEPS (EE.UU.) muestra que encuentra sesgo real.
Estado del proyecto.
v0.1.0publicado y archivado en Zenodo (DOI arriba). La librería de auditoríariskauditestá implementada y testeada (7 funciones, IC de diseño, ~99% de cobertura); el estudio MEPS y un demo auto-contenido corren de punta a punta. Construido por fases segúnPROTOCOL.mdyPLAN.md.
Qué es esto
Los sistemas de salud tienen presupuesto limitado para programas caros —gestión de caso, visitas domiciliarias, seguimiento cercano— así que un modelo decide a quién se prioriza, normalmente prediciendo quién es "de alto riesgo". El problema está en cómo se define ese alto riesgo. La mayoría de los modelos desplegados lo definen como "quién va a gastar más", porque el gasto está registrado para todos.
Esa elección tiene un punto ciego. Pensemos en una persona con distrés psíquico que no consulta: su gasto observado es casi cero, así que un modelo entrenado con gasto la etiqueta como bajo riesgo y la ignora — y la inquietud es que el distrés no tratado pueda reaparecer al año siguiente como otra necesidad médica (urgencias, enfermedades físicas agravadas). La necesidad estaba; la etiqueta nunca la buscó.
Esto es el sesgo por elección de la etiqueta: una falla bien documentada en la que un algoritmo entrenado con el costo como proxy de necesidad sub-atiende a quienes su necesidad aún no se tradujo en gasto. Este proyecto aplica esa mirada a la necesidad de salud mental no tratada y empaqueta la auditoría como herramienta reutilizable.
La idea en una frase: un modelo de riesgo entrenado con gasto sanitario captura la necesidad medida apenas por encima del azar y sub-atiende sistemáticamente a las personas con distrés psíquico — porque es la etiqueta (el gasto), no el algoritmo, la que decide quién es de alto riesgo. Este proyecto mide ese costo de la elección de etiqueta con MEPS.
Qué incluye
riskaudit— paquete Python instalable que audita el sesgo por elección de etiqueta en cualquier modelo de estratificación de riesgo. Es el producto: una herramienta reutilizable que no depende de ninguna base en particular.- Un ejemplo demostrativo sobre MEPS 2021–2023 — pipeline reproducible (tres targets, modelos idénticos, auditoría completa) que muestra que la herramienta encuentra sesgo real con datos de EE.UU. Es una demostración, no el objetivo.
- Demo end-to-end — una corrida auto-contenida sobre datos sintéticos (
demo/run_demo.py), sin credenciales ni datos sensibles, en segundos.
La herramienta riskaudit
riskaudit no entrena modelos ni predice. Es un auditor: le das los puntajes que asignó un modelo y una medida independiente de necesidad real, y cuantifica cuánta necesidad el modelo deja fuera — con pesos poblacionales e intervalos de confianza (bootstrap de filas, o de diseño sobre estratos/PSU de la encuesta).
Como trabaja solo con puntajes y una medida de necesidad, riskaudit 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. El estudio MEPS de salud mental de abajo es un ejemplo demostrativo que muestra que la herramienta encuentra sesgo real — una ilustración, no el alcance de la herramienta.
| Función | Pregunta que responde |
|---|---|
top_k_capture |
De toda la necesidad real de la población, ¿qué fracción cae en el top-k que prioriza el modelo? |
reclassification |
Si cambio la etiqueta de A a B, ¿quién entra y sale de la lista de prioridad? |
ablation |
Si quito un grupo de features y reentreno, ¿cuánto baja el desempeño global vs. cuánto se desploma la captura de ese grupo? |
label_choice_curve |
Curva de percentil de score vs. necesidad observada media — ¿dónde cae la necesidad alta en el score? |
regression_to_mean |
¿Cuánto de la caída de gasto del top-k entre t y t+1 es solo regresión a la media? |
incremental_lift |
La métrica-contribución: entre los que el modelo deprioriza, ¿los que tienen necesidad medida generan más desenlace futuro del predicho que el resto? Hace no-circular el argumento. |
audit_report |
Empaqueta todo en un informe HTML autocontenido. |
Lo que encuentra la auditoría en MEPS (ponderado, con IC de diseño): el modelo de gasto captura solo ~15% de la necesidad K6 del top-decil — apenas sobre el ~10% que daría un score al azar, y muy por debajo del ~41% de un oráculo (rankear por la propia necesidad); un modelo de K6 llega a ~29%. Entre las personas que el modelo de gasto deprioriza, las que están en distrés acumulan más gasto total futuro del que el modelo predijo (lift incremental +0.8 log-dólares con las features de salud mental, +1.0 sin ellas — ambos IC 95% excluyen el cero), así que 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 afirmamos que el distrés se manifieste específicamente como gasto no psiquiátrico — el exceso de gasto total puede ser en parte gasto en salud mental, y un test limpio necesita un target de gasto no-psiquiátrico (backlog). El subgrupo severo no tratado (n ≈ 40) es solo descriptivo.
Instalación y quickstart
Igual que en la sección en inglés (Python ≥ 3.11, pip install -e ".[dev]"). El ejemplo mínimo de la API funciona hoy, y un demo auto-contenido muestra el flujo completo: python demo/run_demo.py (sin datos reales, sin PHI, segundos).
Limitaciones
La honestidad sobre los límites es parte del proyecto, no una nota al pie:
- El subgrupo severo no tratado es pequeño (~135 personas con distrés severo, K6 ≥ 13, en ambos años del panel; el subgrupo sin tratamiento es aún menor): se reporta descriptivamente, 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. Llamar "sesgado" a un modelo exige afirmar cuál es la vara legítima de necesidad. Ese juicio se declara y defiende en
docs/methods.md. - El diseño muestral se respeta siempre: los resultados finales usan pesos y varianza basada en el diseño (bootstrap de clúster estratificado sobre VARSTR/VARPSU, con estimación de subpoblación/dominio).
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file riskaudit-0.1.1.tar.gz.
File metadata
- Download URL: riskaudit-0.1.1.tar.gz
- Upload date:
- Size: 57.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1bd24d7f57146268384ffe99ca14f0b13ec4a9b91f6de003581934fbde4faee4
|
|
| MD5 |
f01ea4069e4a7be1f509061e61d81d8a
|
|
| BLAKE2b-256 |
2e2d1b9a155667f3b9724aaacdafa1ad636e2daf2d30e0edba5f6539da69d66a
|
File details
Details for the file riskaudit-0.1.1-py3-none-any.whl.
File metadata
- Download URL: riskaudit-0.1.1-py3-none-any.whl
- Upload date:
- Size: 31.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fb9d549cd57e5dd7d08f9354dc320b5c47cd307f969595595d03b14b371a6904
|
|
| MD5 |
3fe51755496c9ef166d4ccc843321738
|
|
| BLAKE2b-256 |
8151934b6196bbb0e1ae0e7214c3dc9f30e2f637493e8c3d2415a887a655f047
|