Source: https://oliviercouthaud.com/heartbeatid · Status: Prototype · 2026 · Tags: Python, PyTorch, ECG, Wear OS # HeartbeatID A small neural network that identifies people from a few seconds of heartbeat on public ECG recordings; a Galaxy Watch app is built, with no results from the watch yet. [Image: Histogram of similarity scores. Teal bars (same person) cluster near 0.85. Orange bars (a different person) spread around 0. A dashed cut-off line at 0.48 sits where the two overlap a little; the app uses 0.487.] Figure: How similar a fresh reading was to the stored template, checked on a different day from the one used to set it up. Teal bars: it really was the same person. Orange bars: it was somebody else. The dashed line is the cut-off (0.487 in the app). Five heartbeats per reading, 10 people the network never trained on. The small overlap near the line is where the errors come from. ## What I wanted I wanted to see whether my own watch could tell who is wearing it from heartbeat alone. The model side works on public data. The watch side is the next piece: the app is built, and I have no watch results to report. A Galaxy Watch can record a single-lead ECG when you touch the bezel. The question was whether a few seconds of that signal is enough to say who is touching it, either "is this person X?" (verification) or "who is this?" (identification). The shape of a heartbeat differs between people, but it also changes with heart rate, posture, electrode contact and time of day. So the useful question isn't whether it works within one recording. It's whether it still works on another day. ## How it works The data is the ECG-ID database from PhysioNet: 90 people, 310 recordings of 20 seconds each, Lead I at 500 Hz. I filter the raw channel myself rather than using the pre-filtered one, so the exact same filter can run on the watch. R-peaks come from a small Pan-Tompkins-style detector built on SciPy. Each heartbeat is cut from 250 ms before the peak to 400 ms after, resampled to 256 samples and z-normalised. That gives 10,424 beats. **1. Bandpass.** A third-order Butterworth filter, 0.5 to 40 Hz, run on the raw channel. It takes out the slow baseline drift and the high-frequency fuzz and keeps the shape of the beat. **2. R-peaks.** A Pan-Tompkins-style detector: take the slope, square it, smooth it over about 120 ms, then pick peaks with an adaptive threshold and a minimum gap between beats. **3. Cut the beat.** 250 ms before each R-peak to 400 ms after, resampled to 256 samples and z-normalised, so what is left is shape, not size. **4. The network.** Four small convolution blocks and a linear layer, 101,760 parameters. One beat in, a 128-number embedding of length 1 out. **5. Compare.** Average the embeddings of 5 beats, take the cosine against the stored template, and accept if the score is 0.487 or higher. Figure: Diagram. Tap or click a stage, or use the buttons above. The 0.487 threshold is one global value: the 5-beat equal-error point of the 10 cross-day held-out people. The network is four small convolution blocks and a linear layer, about 0.1 million parameters (101,760). It trains in roughly 30 seconds for 25 epochs on a CPU and embeds a beat in under a millisecond. What comes out for each beat is a list of 128 numbers. **What is an embedding, and what is cosine similarity?** An embedding is the list of numbers a network produces for an input, here 128 numbers per heartbeat. Training pushes beats from the same person towards similar lists and beats from different people apart. The lists are scaled to length 1, so comparing two of them is just a dot product, the cosine similarity: 1 means pointing the same way, 0 means unrelated, below 0 means opposite. I train it with an ArcFace loss (scale 16, margin 0.30) over the training people, because the real task is open-set: compare two embeddings, not pick from a fixed list of classes. The ArcFace head only exists during training. **What is ArcFace?** ArcFace is a loss from face recognition. Plain classification only asks the network to pick the right person. ArcFace also demands that a beat sit a fixed angle (here 0.30 radians) closer to its own person than to anyone else, which squeezes each person's embeddings into a tight cluster. That is what you want when the job at test time is to compare two embeddings, not to choose from a list. To enrol, I average the embeddings of up to 20 beats from one day into a template. To check someone, I average the embeddings of 5 beats and take the cosine similarity to the template. At 0.487 or above the answer is yes. That number is one global value for everybody. The split holds out whole people: 62 for training, 28 for testing. The test people are new to the network, like a new user enrolling. Only 20 of the 90 have recordings from more than one day, and half of those 20 are in the test set. Figure: Diagram. One square is one person. The cross-day numbers below come from the 10 orange squares on the lower row. ## Results **Held-out people only. The same-session row is optimistic: enrolment and test beats come from the same day (first and second half of that day's beats). Numbers in brackets are how many beats were averaged. Top-1 is among 28 people in the first row (chance 3.6%) and among 10 in the second (chance 10%). EER is the equal error rate, explained below.** | Setting | Top-1 (5) | EER (5) | EER (10) | |---|---|---|---| | Cross-day, 10 people | 92.4% | 7.4% | 7.2% | | Same session, 28 people (optimistic) | 96.7% | 2.7% | 1.8% | **What is the equal error rate (EER)?** A verifier can fail two ways: accept the wrong person (false accept) or reject the right one (false reject). Moving the cut-off trades one for the other. The equal error rate is the cut-off where the two happen equally often, and it gives one number to compare systems by. Lower is better, and 7.4% means about 7 errors of each kind in 100 at that setting. The cross-day row is the one I trust: enrol on one day, verify on another. "Another day" means each person's first recording date against their last. The gap varies by person and I haven't tabulated it. At 5 beats, error rises from 2.7% to 7.4% between the two rows. Session drift is the likely cause, but the rows use different people (28 vs 10) and different protocols, so it isn't a controlled measurement. You can see the trade-off in the actual scores. This is the 5-beat cross-day case from the plot at the top, 800 readings of the right person and 800 of other people. Drag the cut-off and the two error rates move in opposite directions. ### Move the cut-off on the real scores **Rates at five cut-offs, same data as the histogram.** | Cut-off | Strangers let in | Owner turned away | |---|---|---| | 0.30 | 21% | 0% | | 0.40 | 15% | 2% | | 0.487 | 8% | 9% | | 0.60 | 2% | 15% | | 0.70 | 0% | 20% | 800 genuine and 800 impostor scores, 5 beats, enrolled on one day and checked on another, 10 held-out people. The rates are rebuilt from the histogram at the top, so they are approximate, within a few points. The app is set to 0.487. The plots here come from their own random draw of windows (a different seed, 80 per person), so their error rate (8.4%) differs from the 7.4% in the table. That gap is a sign of how much these numbers move on 10 people. **More beats help, with smaller gains after 5. Cross-day, 10 held-out people.** | Beats averaged | Top-1 ID | Verification EER | |---|---|---| | 1 | 83.4% | 9.6% | | 5 | 92.4% | 7.4% | | 10 | 93.6% | 7.2% | At a resting 60 to 70 beats a minute, 5 beats is about 4 to 5 seconds and 8 beats about 7 to 8, and the watch app records a fixed 8 seconds. Across all 20 multi-day people, including ones the network trained on, the numbers are 94.5% top-1 and 4.0% EER at 5 beats. That row isn't an unseen-identity result, so I don't quote it as one. [Image: ROC curve for verification, cross-day, 5 beats, held-out people. AUC 0.986, EER point marked at 0.084] Figure: ROC for the same setting: enrol on one day, verify on another, 5 beats, held-out people. AUC 0.986. ## The watch Figure: Diagram. The Wear OS app as I built it, from fingertip to name. I have no results from it on the watch yet. I built a Wear OS app for the Galaxy Watch 8. It was meant to capture about 8 seconds of ECG through Samsung's Health Sensor SDK while you keep a finger of your other hand on the bezel, run the preprocessing in Kotlin, run the model as ONNX, and keep a roster of enrolled initials on the watch. I checked a Python copy of the Kotlin filter and peak finder against the original and the peaks were identical. The Kotlin code itself has no automated tests, and the ONNX output matches PyTorch to about 1.7e-7. Then I ran the app on an actual Galaxy Watch. From what I remember it captured the ECG and ran the model on the watch while it listened, and I don't remember it showing a score. The code does have a result line on the watch screen (the initials and a percentage, "not recognized", or an error message if the capture fails), so checking what the screen actually did, and getting a result to show every time, is part of getting it solid. That is the next piece of work. It spans the watch's operating system and my own code, and debugging means connecting the watch over ADB, trying a change and repeating, which is slow. I have no accuracy numbers from the watch, so I make no claims about it. The model was trained on clinical ECG and the watch reads a different lead. The idea was to enrol every person on the watch itself, so a person's template and later readings come from the same sensor. That matters once the app runs solidly, and whether enrolling on the watch closes the gap is yet to be tested. The app is not published. There's also a second model trained on wrist PPG from PPG-DaLiA: 15 people, a motion-heavy field study, downsampled to 25 Hz, in 8-second windows. Its error rates are higher. On 4 held-out people, enrolling on the first part of a recording and verifying on the last, EER is 36.2% with 1 window, 26.8% with 5 and 17.0% with 10, and top-1 is 73% with 10 windows. **What is PPG?** PPG, photoplethysmography, measures blood volume under the skin. A green LED on the back of the watch shines into the wrist and a sensor reads how much light comes back, which rises and falls with each pulse. It needs no finger on the bezel, but the signal is smoother and much more sensitive to motion than an ECG. ## What I ran into, and the caveats The watch itself. The app is built and the work left is getting it solid: it involves the watch's operating system and my code, and each attempt means connecting over ADB, trying and repeating. Beyond that there is a domain gap to cover: ECG-ID is Lead I from a lab, while the watch measures from finger to wrist with more motion and lower signal quality. I expect the threshold to need re-fitting on watch data. What exists is the algorithmic core, not a validated on-watch system. Session drift, probably. Error roughly triples between same-session and cross-day (2.7% to 7.4% at 5 beats), and I can't separate drift from the difference between the two groups of people. A larger multi-day cohort is what would show how large the effect really is. The test is small. The cross-day headline rests on 10 people from one split with one seed, with no confidence interval. Its 500 genuine and 500 impostor scores are repeated random draws of beats from those same recordings, so they are not 500 independent trials. The threshold I set in the app is the equal-error point of those same 10 people, so it is tuned to the test set. PPG has a harder time, for understandable reasons: only 4 unseen people, heavy motion, and at 25 Hz the dicrotic notch is about one sample wide. The 9.2% EER on all 15 people includes training identities and is optimistic. There is also no protection against replayed or synthetic ECG, and the threshold is global rather than per user. ## Where it stands First, get the app solid on the watch and returning a result. Then collect a multi-day dataset from the watch itself, fine-tune the last blocks and re-fit the threshold on it, set thresholds per user after enrolment, and add liveness checks. Those are the steps ahead. - **Stack**: Python, PyTorch, SciPy, wfdb, scikit-learn; Kotlin, Wear Compose and ONNX Runtime for the watch app - **Data**: ECG-ID (PhysioNet), PPG-DaLiA. Public datasets, not redistributed here. - **Status**: Prototype - **Watch app**: Built; no results from the watch yet - **Credit**: ArcFace (loss) and Pan-Tompkins (R-peak detection) are published methods. Libraries and datasets belong to their authors. - **Code**: Not public [← All work](https://oliviercouthaud.com/projects)