Who to call after a hospital discharge
A care team that can call one discharged patient in ten needs the right tenth, so this project judges readmission models by how many readmissions a fixed call capacity reaches, not by AUC alone. It is the same problem I worked on at Cigna Healthcare, here on public data.
- Data
- 101,766 diabetic inpatient encounters from 130 US hospitals (UCI, 1999 to 2008)
- Held out
- 19,745 encounters from 13,908 patients the models never saw
- Result
- A 10% call list reaches 25.0% of 30-day readmissions, against 20.5% for most prior stays first
- Serving
- 72 single-encounter requests a second on one core, 18.7 ms p95
Plan the calls
Drag the slider or click the chart. Each line ranks the 19,745 held-out encounters a different way and calls from the top.
- LightGBM
- Most prior stays first
- LACE score
- Calling at random
| Who gets called first | Readmissions reached per 1,000 discharges | Share of all readmissions | Calls that reach one |
|---|---|---|---|
| LightGBM | 29 | 25.0% | 28.8% |
| XGBoost | 29 | 24.8% | 28.7% |
| Logistic regression | 27 | 23.0% | 26.5% |
| Most prior inpatient stays first | 24 | 20.5% | 23.7% |
| LACE score | 18 | 15.8% | 18.3% |
| Calling at random | 12 | 10.0% | 11.5% |
How the list is built
- UCI extract101,766encountersdiabetic inpatient encounters, 130 US hospitals, 1999 to 2008
- Exclusions2,423removeddeaths and discharges to hospice, who can't be readmitted
- Split by patient19,745held out60/20/20 by a hash of the patient number
- LightGBM287trees25 features, early stopping and isotonic calibration on validation
- Call list1,974callsthe top 10% of discharges by risk, three TreeSHAP reasons each
- Death and hospice discharges are dropped: those patients can't come back, and keeping them lifts AUROC to 0.679 for the wrong reason.
- Race and gender aren't features; they're kept for the subgroup checks. As features they don't help (24.7% on the list).
- LACE is approximate here: the extract has three diagnosis codes and counts emergency visits over a year, not six months.
The 25 features
Prior-year inpatient, emergency and outpatient visits; length of stay; lab, procedure and medication counts; the three diagnoses grouped as in Strack et al. (2014); a Charlson score from those ICD-9 codes; LACE; admission type and source; discharge disposition; payer; admitting specialty; A1C and glucose results; insulin, metformin and medication changes; age band.
Why each patient is on the list
Every call comes with the three things that raised its score most, from exact TreeSHAP, in words a care coordinator would use; CI checks the code descriptions against the table that ships with the UCI download. Prior inpatient stays lead for 61% of the list and the discharge disposition for 37%. A reason explains the score, not the patient.
| Rank | Risk | Age | Back in 30 days | Reasons |
|---|---|---|---|---|
| 1 | 67% | 40s | yes | 10 inpatient stays in the prior year; Charlson comorbidity score 10; third diagnosis: neoplasms |
| 2 | 67% | 20s | yes | 14 inpatient stays in the prior year; transferred to another inpatient care facility; primary diagnosis: diabetes |
| 3 | 65% | 20s | no | 18 inpatient stays in the prior year; 9 emergency visits in the prior year; LACE score 13 |
| 4 | 64% | 20s | yes | 21 inpatient stays in the prior year; 1 emergency visit in the prior year; second diagnosis: digestive |
| 5 | 64% | 20s | yes | 9 inpatient stays in the prior year; Charlson comorbidity score 3; age 20 to 30 |
Score a patient
The model's trees run in your browser and match LightGBM's own scores; the tests check 300 held-out rows. Anything not on the form is held at its median or most common value.
9.1% chance of a readmission within 30 days, against 11.5% across the held-out encounters. This patient doesn't make the 10% call list.
How far to trust it
Against the prior-stays rule the list reaches 4.4 points more of the readmissions (95% interval +2.7 to +6.0), 101 more patients who came back. XGBoost ties LightGBM (24.8% against 25.0%); LightGBM ships because it led on validation; the held-out split played no part in choosing it.
The probabilities said the list would hold 539 readmissions, and it held 569. Every tenth of predicted risk is within 1.6 points of what happened.
| Tenth by risk | Encounters | Predicted | Observed |
|---|---|---|---|
| 1 | 1,975 | 3.8% | 4.1% |
| 2 | 1,975 | 5.0% | 6.2% |
| 3 | 1,975 | 6.8% | 6.4% |
| 4 | 1,975 | 8.5% | 8.4% |
| 5 | 1,975 | 9.3% | 9.3% |
| 6 | 1,974 | 10.5% | 10.5% |
| 7 | 1,974 | 11.6% | 10.8% |
| 8 | 1,974 | 13.0% | 13.3% |
| 9 | 1,974 | 18.0% | 17.5% |
| 10 | 1,974 | 27.3% | 28.9% |
The list has a blind spot: 41% of readmissions were patients with no visit in the year before, and they're 833 of its 1,710 misses. 78% of the list had a prior inpatient stay, against 33% of all discharges. The model finds people with a history; it misses people the record says little about.
- On the 10% call list
- Missed
What I'd do next: evaluate the 943 readmissions among patients with no visit in the prior year as a group of their own, and check how well the features the model still has for them (diagnoses, length of stay, labs, medications, discharge disposition, age) separate those who come back from those who don't. If they separate them poorly, I'd look for data the extract doesn't have, such as admission dates or notes, before assuming a more complex model would help. I haven't run this subgroup study.
What I checked
Simulated shifts
The extract has no admission dates, so I shift the held-out set on purpose and check what the monitoring catches. The first two shifts resample real encounters with their real outcomes.
| What was shifted | Readmissions on the list | Expected on list | Observed | Alerts |
|---|---|---|---|---|
| Nothing | 25.0% | 539 | 569 | none |
| Patients 70 and over made 70% of discharges (45% in test) | 23.9% | 523 | 585 | PSI on age |
| Patients with a prior inpatient stay made 60% (33% in test) | 23.4% | 600 | 628 | PSI on prior inpatient stays |
| Second and third diagnoses blanked on a random 30% | 25.1% | 533 | 571 | missing-value rate on both |
Calibration slips most for the older population (523 expected on the list, 585 observed). Blanking diagnoses fires no PSI alert, the largest being 0.028 against a threshold of 0.2; only the missing-value check catches it, so monitoring needs both.
Who the list reaches
The list reaches 31.1% of readmissions under 50, 26.7% at 50 to 69 and 21.5% at 70 and over. The 417 encounters with no race recorded get 2 of their 31 readmissions on the list: too few to conclude much from, and the group I'd watch.
Every subgroup
| Group | Encounters | Readmitted | On the list | Readmissions on the list |
|---|---|---|---|---|
| Age | ||||
| Under 50 | 3,191 | 12.0% | 12.0% | 31.1% |
| 50 to 69 | 7,687 | 10.6% | 9.7% | 26.7% |
| 70 and over | 8,867 | 12.2% | 9.5% | 21.5% |
| Gender, as recorded | ||||
| Women | 10,609 | 11.6% | 10.3% | 26.0% |
| Men | 9,136 | 11.5% | 9.6% | 23.7% |
| Race, as recorded | ||||
| African American | 3,692 | 11.7% | 10.5% | 23.9% |
| Caucasian | 14,806 | 11.7% | 10.1% | 25.3% |
| Hispanic | 400 | 10.8% | 8.0% | 23.3% |
| Other | 288 | 9.7% | 10.1% | 39.3% |
| Not recorded | 417 | 7.4% | 4.1% | 6.5% |
Choices I tested
I retrained LightGBM with one decision changed at a time. The two scored on the same held-out split, class weights and race and gender as features, both land below the model as built.
Every variant
| LightGBM retrained with | AUROC | Readmissions on the 10% list | Test encounters |
|---|---|---|---|
| As built | 0.673 | 25.0% | 19,745 |
| Split by encounter instead of patient | 0.672 | 22.6% | 19,910 |
| Death and hospice discharges kept | 0.679 | 25.2% | 20,225 |
| Class-weighted training | 0.672 | 24.5% | 19,745 |
| Race and gender as features | 0.673 | 24.7% | 19,745 |
As a service
POST /score returns calibrated risk, whether the encounter makes the call list at the configured capacity, and its three reasons. On one core it answers 71.7 single-encounter requests a second (p95 18.7 ms) and scores about 515 encounters a second in batches of 100, most of that time on exact TreeSHAP. No request failed.
Show every load level
| Encounters per request | Clients | Encounters/s | p50 | p95 |
|---|---|---|---|---|
| 1 | 1 | 72 | 13.3 ms | 18.7 ms |
| 1 | 2 | 74 | 26.1 ms | 34.9 ms |
| 1 | 4 | 79 | 50.3 ms | 70.6 ms |
| 1 | 8 | 75 | 104.8 ms | 153.7 ms |
| 100 | 1 | 514 | 191.0 ms | 218.8 ms |
| 100 | 2 | 520 | 381.4 ms | 418.8 ms |
| 100 | 4 | 511 | 778.7 ms | 850.0 ms |
| 100 | 8 | 510 | 1560.7 ms | 1709.4 ms |
Run it yourself
make install # the package and dev tools
make run # download (checked against its SHA-256), train, evaluate
make test # lint, tests, and the browser scorer against a fresh export
make loadtest # the service on one core, the load generator on the other
make export # trees, calibration, capacity curves and the call list for this page
On every push, CI downloads the extract, retrains with the locked libraries, and fails if AUROC, AUPRC or Brier move by more than 0.002, or recall at 10% by more than 0.005, from the committed results.
Numbers on this page are from the code at commit d7a05c6, run on 2026-10-21 with LightGBM 4.7.0 and XGBoost 3.4.1. Every table readmit run writes is in RESULTS.md.