Projects · Health · Machine Learning · Jul 2026

Sepsis Early Warning

Flags 93% of septic ICU patients a median 12 hours before onset, and reports the false alarms too.

One patient, replayed hour by hour

The model crossed its alert threshold at hour 60. Clinical onset came at hour 69. Shaded: the window the official metric rewards, 12 hours before onset to 3 hours after.

00.20.40.60.8 risk 10203040506070 hours in ICU alert threshold 0.32 onset, hour 69 first alert, 9 h early 00.20.40.60.8 risk 10203040506070 hours in ICU alert threshold 0.32 onset, hour 69 useful window first alert, 9 h early

Source: PhysioNet 2019 Challenge, held-out patient p010983. Scores precomputed by the committed model for the project's live demo.

On 4,441 patients it never saw in training, it flagged 93% of septic cases in time. It also alarmed on 59% of the patients who were fine, which is the number most launch posts would leave out.

The Problem

Every hour in an intensive care unit brings new vitals and labs. The PhysioNet/Computing in Cardiology Challenge 2019 asked a precise question about that stream: given everything recorded about a patient up to this hour, should the bedside get a sepsis warning now?

The Challenge’s own utility metric encodes the clinical trade-off. It rewards warnings 6 to 12 hours before onset, penalizes late or missed cases heavily, and charges a small cost for every false alarm. I scored against that metric directly instead of a proxy, because an hourly AUROC says nothing about whether an alert arrives in time.

What I Built

A gradient-boosting model that scores every ICU hour for every patient. When the hourly risk crosses 0.32, the alert fires. I trained it on 13,321 patients from the Challenge’s training set A and held out 4,441 more, split by patient, for the final test.

Around the model sits the usual plumbing, done carefully: a FastAPI service with a validated POST /score endpoint, a Docker image, CI that runs lint, tests and a container smoke test on every push, and a static demo that replays seven held-out patients hour by hour. Every score on the demo comes from the committed model, through the same validation and feature code as the API.

How It Works

  1. load.pyReads the Challenge’s per-patient .psv files: one row per ICU hour, 40 columns.
  2. features.pyBuilds causal time-series features. Every feature at hour t uses data up to t only: forward-fills, rolling and expanding windows, plus “measured this hour” and “hours since last measured” flags, because when a clinician orders a lab is itself a signal.
  3. train.pyPatient-grouped train/test split and CV folds. Tunes the alert threshold on 3-fold out-of-fold predictions, not on the model’s own training scores.
  4. evaluate.pyOfficial Challenge utility (the organizers’ code, vendored), AUROC/AUPRC, and a lead-time report that only counts alerts inside the rewarded window.
  5. app.py and the demoFastAPI scorer at POST /score. At build time, each demo patient is sent through the real endpoint and the responses ship with the static page.

Three decisions did most of the work:

  • Leakage is tested, not trusted. A unit test appends and corrupts future hours, then checks that every earlier feature is unchanged. In clinical time series, leakage hides in the plumbing. Writing that test was cheaper than auditing every feature by hand.
  • The threshold is tuned out-of-fold. In-sample utility was 0.67 against 0.42 on test. Tuning on in-sample scores would have picked a threshold for overconfident predictions.
  • Lead time is counted honestly. An alarm 30 hours before onset is not early detection; it fired before anything predictive was observable. Those alarms are reported separately instead of padding the average.

Results

Metric, 4,441 held-out patientsValue
Normalized utility (official Challenge metric)0.424
AUROC / AUPRC (hourly)0.817 / 0.092
Septic patients flagged in the useful window (12 h before to 3 h after onset)370 / 398 (93%)
Median warning before onset, among those flagged12 h
Patients without sepsis with at least one alarm2,381 / 4,043 (59%)
Septic patients who also alarmed before the useful window271 / 398 (68%)

The threshold picked by cross-validation, 0.32, landed at the peak of the test utility curve, so the tuning generalized instead of overfitting.

Choosing where the alarm goes off
Line chart of normalized challenge utility on the 4,441 held-out patients across alert thresholds from 0.05 to 0.8. Utility climbs from about minus 0.1, peaks at 0.423 at the threshold of 0.32 chosen by cross-validation, then falls to about 0.16 at 0.8.

Source: docs/utility_vs_threshold.png in the project repository.

What 93% detection costs

Share of held-out patients, at the threshold chosen by cross-validation (0.32).

Septic patients flagged in the useful window 93% 370 of 398 Septic patients who also alarmed earlier 68% 271 of 398 Patients without sepsis who got an alarm 59% 2,381 of 4,043 Septic patients flagged in the useful window 93% 370 of 398 Septic patients who also alarmed earlier 68% 271 of 398 Patients without sepsis who got an alarm 59% 2,381 of 4,043

Source: PhysioNet 2019 Challenge, 4,441 held-out patients. sepsis-early-warning/artifacts/clinical_report.json

The last two bars are the cost of the first one. They belong in the same chart.

Limitations

Correction

An earlier version of my resume compared its score with the Challenge leaderboard, where the best official entries scored about 0.36. That comparison does not hold. Those entries were scored on hidden test sets that included a hospital no team had data from. This result is an internal split of a single hospital system, which is a much easier setting.

  • High alarm burden. At the utility-optimal threshold, most patients without sepsis alarm at least once during their stay. The metric charges only 0.05 per false-positive hour, so it tolerates this. A real deployment would need alert suppression or a stricter threshold.
  • Partial training data. Trained on 17,762 of the 20,336 patients in training set A. Training set B, the second hospital, was not used.
  • A strong baseline, not the state of the art. Leading approaches add sequence models and per-hospital calibration. These probabilities are not calibrated.
  • Research demo only. Not a medical device and not validated for clinical use. The demo replays de-identified Challenge patients and stores nothing.

What I’d Do Next

  1. Train on hospital A, test on hospital B. That is the honest external-validation experiment, and the number I would most like to see.
  2. Add alert suppression or a stricter threshold, then report the alarm burden again next to the detection rate.
  3. Try sequence models and per-hospital calibration, the approach the leading entries used, and keep this model as the baseline to beat.