← Projects

ECG Screening Toolkit

A machine learning system that reads an ECG and flags what warrants a clinician's attention. Version one detects atrial fibrillation; the roadmap extends to ischemic ST changes, conduction abnormalities, and ECGs captured as photographs.

Status
v1 live at ecg.bentileo.tech
Stack
Python · scikit-learn · Streamlit · Supabase · nginx
Data
PTB-XL — 21,799 clinical ECGs, 18,869 patients
Period
2026
Try the demoSource code

The problem

Atrial fibrillation is common, often silent, and carries a stroke risk that anticoagulation can substantially reduce. The diagnosis hinges on a pattern that is visually obvious once you know it: the intervals between beats become irregularly irregular, with no underlying periodicity at all.

SINUS RHYTHM — evenly spaced ATRIAL FIBRILLATION — irregularly irregular
The signal the model learns. Both strips are ten seconds of lead II.

Decisions, and why

The target changed once the features were measured

The original goal was normal-versus-abnormal classification. Measurement showed that was not supportable. The features here derive from R-peak positions, which describe rate and rhythm. But “abnormal” in this dataset is dominated by myocardial infarction, hypertrophy, and ischemia — conditions whose signatures live in wave morphology. A patient with an old anterior infarct sits at 72 beats per minute, perfectly regular, with a pathological Q wave that no interval measurement can see.

Tested against that target, the rhythm features did not separate the classes at all: median RR variability was 0.028 in both groups. Against atrial fibrillation they separate sharply — 0.197 against 0.026, with the 25th percentile of the AFib group sitting above the 75th percentile of everything else.

Wave delineation was measured and rejected

PR, QRS, and QT features were attempted first. Against a recording with a directly verified 94 ms QRS, the delineator returned per-beat values from 78 to 208 ms across a single regular rhythm. That is not physiological variation; it is boundary detection failing.

Why bounds could not rescue it. A 168 ms QRS is anatomically possible in bundle branch block, so no sanity check rejects it — even when the true value is 94 ms. Bounds catch the impossible, not the merely wrong.

Splitting by patient, not by recording

Several patients contribute more than one recording. A random split would place one patient’s recordings on both sides of the train and test boundary, letting the model score well by recognizing the person rather than the pathology. Every fold is grouped by patient identifier and verified to share zero patients across train and test.

The labels were verified against the source

An apparent contradiction appeared early: 37 recordings carried both a “normal” and an “atrial fibrillation” code. Reading the cardiologists’ free-text reports resolved it. Those records say things like “atrial fibrillation. otherwise no definite pathology.” The normal code means otherwise normal.

Excluded recordings are label-biased, and this is stated

223 recordings, about 1%, were removed because R-peak detection failed. Those exclusions are not random: 15% of them are atrial fibrillation against a 6.9% baseline, because fibrillatory baselines are genuinely harder to delineate than clean regular rhythms. Removing them deletes some of the hardest positives and makes the reported metrics modestly optimistic.

Results

Patient-grouped, label-stratified five-fold cross-validation across 21,576 recordings. Accuracy is not reported: with 6.9% positives, a model that never predicts atrial fibrillation is 93% accurate and clinically useless.

Threshold Recall Precision
0.10 — operating point 0.96 0.34
0.20 0.92 0.41
0.30 0.87 0.46
0.50 — default 0.74 0.58

The operating threshold is a clinical decision

The threshold sits at 0.10 rather than the conventional 0.50, because the two errors are not equally costly. A missed case leaves a patient at unmonitored stroke risk. A false positive costs a confirmatory review.

Maximizing recall is defensible only because flagged recordings receive human confirmation. A fully autonomous system would need a higher-precision operating point, because a tool that cries wolf gets ignored, and alert fatigue costs sensitivity in practice that no confusion matrix records.

Limitations

Architecture

PTB-XL waveforms

R-peak detection

RR-interval features

Signal-quality filtering

Patient-grouped cross-validation

Random forest

Streamlit dashboard on a VPS

Roadmap

Image digitization. Accept a photograph of a paper ECG, recover the underlying signal, and classify it. This is the version usable outside a research dataset.

Morphology features. Revisit delineation with a delineator validated against annotated boundaries, using the dataset’s own bundle branch block and AV block labels as ground truth. This is what extends the toolkit beyond rhythm to ischemic ST changes and conduction disease.

Explainability. Surface which features drove a prediction, so a clinician sees why a recording was flagged rather than trusting a probability.

What running this publicly required

A model in a notebook has no attack surface. One accepting uploads on the open internet does, and closing it took as much thought as the model.

Screening results are stored in Supabase, which means a credential ships inside the application and must be assumed public. So the database uses two keys rather than one: a publishable key with permission to insert a result and nothing else, and a secret key that reads them back. Streamlit renders on the server rather than in the browser, so the second key never reaches a client. The history view is additionally gated behind a password, and the database refuses reads from the public key even if the interface were bypassed.

Uploads are bounded before any processing — file size, sample count, numeric content, and amplitude, each rejected with a message a person can act on rather than a stack trace. Inserts are rate limited, and a check constraint rejects values the model could not have produced.

The waveform itself is never stored. It exists in memory for one screening and is discarded; only the outcome is recorded. The repository’s SECURITY.md states what this protects and, more usefully, what it does not.