Projects

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.

10% of discharges, 100 calls per 1,000 patients sent home
  • LightGBM
  • Most prior stays first
  • LACE score
  • Calling at random
Figure 1. Share of the 2,279 held-out readmissions each ranking reaches as the team calls more of the 19,745 discharges, highest risk first. The vertical line is the slider.
Who gets called firstReadmissions reached per 1,000 dischargesShare of all readmissionsCalls that reach one
LightGBM2925.0%28.8%
XGBoost2924.8%28.7%
Logistic regression2723.0%26.5%
Most prior inpatient stays first2420.5%23.7%
LACE score1815.8%18.3%
Calling at random1210.0%11.5%

How the list is built

  1. UCI extract101,766encountersdiabetic inpatient encounters, 130 US hospitals, 1999 to 2008
  2. Exclusions2,423removeddeaths and discharges to hospice, who can't be readmitted
  3. Split by patient19,745held out60/20/20 by a hash of the patient number
  4. LightGBM287trees25 features, early stopping and isotonic calibration on validation
  5. Call list1,974callsthe top 10% of discharges by risk, three TreeSHAP reasons each
Figure 2. From the public extract to the 10% call list on the held-out split.
  • 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.

The top of the 10% call list, held-out encounters
RankRiskAgeBack in 30 daysReasons
167%40syes10 inpatient stays in the prior year; Charlson comorbidity score 10; third diagnosis: neoplasms
267%20syes14 inpatient stays in the prior year; transferred to another inpatient care facility; primary diagnosis: diabetes
365%20sno18 inpatient stays in the prior year; 9 emergency visits in the prior year; LACE score 13
464%20syes21 inpatient stays in the prior year; 1 emergency visit in the prior year; second diagnosis: digestive
564%20syes9 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 riskEncountersPredictedObserved
11,9753.8%4.1%
21,9755.0%6.2%
31,9756.8%6.4%
41,9758.5%8.4%
51,9759.3%9.3%
61,97410.5%10.5%
71,97411.6%10.8%
81,97413.0%13.3%
91,97418.0%17.5%
101,97427.3%28.9%
Figure 3. Predicted against observed 30-day readmission rate for each tenth of the held-out encounters, ranked by LightGBM's calibrated risk. A point on the diagonal is perfectly calibrated. The highlighted row is the top tenth, the same size as the call list.

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
Figure 4. The 2,279 held-out readmissions, by whether the patient had an inpatient, emergency or outpatient visit in the year before.

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 shiftedReadmissions on the listExpected on listObservedAlerts
Nothing25.0%539569none
Patients 70 and over made 70% of discharges (45% in test)23.9%523585PSI on age
Patients with a prior inpatient stay made 60% (33% in test)23.4%600628PSI on prior inpatient stays
Second and third diagnoses blanked on a random 30%25.1%533571missing-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
GroupEncountersReadmittedOn the listReadmissions on the list
Age
Under 503,19112.0%12.0%31.1%
50 to 697,68710.6%9.7%26.7%
70 and over8,86712.2%9.5%21.5%
Gender, as recorded
Women10,60911.6%10.3%26.0%
Men9,13611.5%9.6%23.7%
Race, as recorded
African American3,69211.7%10.5%23.9%
Caucasian14,80611.7%10.1%25.3%
Hispanic40010.8%8.0%23.3%
Other2889.7%10.1%39.3%
Not recorded4177.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 withAUROCReadmissions on the 10% listTest encounters
As built0.67325.0%19,745
Split by encounter instead of patient0.67222.6%19,910
Death and hospice discharges kept0.67925.2%20,225
Class-weighted training0.67224.5%19,745
Race and gender as features0.67324.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 requestClientsEncounters/sp50p95
117213.3 ms18.7 ms
127426.1 ms34.9 ms
147950.3 ms70.6 ms
1875104.8 ms153.7 ms
1001514191.0 ms218.8 ms
1002520381.4 ms418.8 ms
1004511778.7 ms850.0 ms
10085101560.7 ms1709.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.