Olivier Couthaud

HeartbeatID

A small neural network that identifies people from a few seconds of heartbeat. It works on public ECG recordings; the Galaxy Watch app I built around it never got working.

What it is

I wanted to find out whether my watch could tell who is wearing it from nothing but a heartbeat, like a fingerprint made of heartbeats. The first half worked: on public recordings, a small program picks people out well. The second half, getting it to run on the watch itself, did not.

Your heart gives off a small electrical signal, the up-and-down line a hospital ECG draws, and its shape is a little different from person to person. A Galaxy Watch can record it when you touch the metal edge with a finger of your other hand. The question was whether a few seconds of it is enough to say who is touching it.

One heartbeat is a shaky clue, like recognising handwriting from a single letter. A whole line is much easier, so the system averages about five beats, which is around five seconds.

How it works

A small neural network, a program that learns patterns from examples, boils each heartbeat down to a short list of numbers, a kind of signature. On set-up day it stores your average signature. Later it takes a fresh reading and checks how close the new signature is to the stored one. Close enough, and it says it's you.

1. Set-up day About 5 heartbeatsa few seconds on the watch Small neural networkeach beat becomes numbers Stored signaturethe average of those beats 2. Another day About 5 new heartbeatssame watch, a later day Same neural networka fresh signature Comparehow close to stored? Close enough?yes: you. no: not you
Diagram. The stored signature is the average of up to 20 beats. A fresh reading is the average of 5.

Does it work

The numbers below come from public ECG recordings of 90 people, not from me or from the watch. The network learned from 62 of them and I tested it on people it had never seen, which is what a new user would be. The hard version of the test stores the signature from one day and checks it on another, because a heartbeat shifts with posture, stress and how well the contacts touch the skin. Only 10 people had recordings from two different days, so that test is small. On it, picking the right person out of the ten worked about 9 times in 10. Used as a lock, at the best setting roughly 7 strangers in 100 got in and roughly 7 real owners in 100 were turned away.

The cut-off decides how strict it is. Move it and watch the two mistakes trade places.

Set how strict the watch is

With the cut-off at 0.30, about 21 strangers in 100 get in and fewer than one real owner in 100 is turned away. At 0.49, about 8 strangers in 100 get in and about 9 real owners in 100 are turned away. At 0.70, almost no strangers get in and about 20 real owners in 100 are turned away.

From the same test, but this chart uses a separate random draw of readings: 800 of the right person and 800 of other people, five heartbeats each. So it differs by a point or two from the 7 in 100 above. The rates are rebuilt from a chart of the scores, so treat them as rough, within a few points.

The watch

I wrote an app for a Galaxy Watch that was meant to record the ECG, make the signature and do the matching on the watch itself. I never got a working result out of it. There were problems with the watch's operating system and problems in my own code, and debugging on a watch is slow and fiddly: connect it with a tool called ADB, try something, watch it fail, repeat. So the watch has no accuracy numbers, and I won't make any up.

Even once it runs, there is a second problem. The recordings the network learned from were made in a lab, while a watch measures from a finger to the wrist, which is a different and noisier signal.

I also trained a second model on public recordings from the kind of green light sensor on the back of a watch. That one needs no finger on the edge, but it was much worse on the public data I tried: roughly 17 mistakes of each kind in 100 when it averaged ten 8-second readings, on only four people, and much worse on shorter readings. And nothing here can tell your heartbeat from a recording of it, so it isn't something to guard a phone with.

What's next

First, get the app running on the watch at all. After that, record heartbeats on the watch itself, from several people over several days, teach the network from those recordings, and set the strictness for each person instead of one setting for everyone.

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.
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 mistakes 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 of that worked on public data. The watch side did not, and I never got a working result from it.

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.

Raw Lead-I ECG500 Hz Bandpass0.5-40 Hz, order 3 R-peak detectPan-Tompkins style Window + z-norm-250..+400 ms BeatEmbedder1-D CNN, 0.10 M Embedding128-D, L2-normed ArcFace headtraining only same pipeline for enrolment beats and query beats Template (day A)mean of up to 20 beats cosine scoreaccept if >= 0.487 Query (day B)mean of 5 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.

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.

Trained on: 62 people Held out for testing: 28 people the 10 for the cross-day test recordings from one day only recordings from more than one day
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.
SettingTop-1 (5)EER (5)EER (10)
Cross-day, 10 people92.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 mistakes 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-offStrangers let inOwner turned away
0.3021%0%
0.4015%2%
0.4878%9%
0.602%15%
0.700%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, then stop helping. Cross-day, 10 held-out people.
Beats averagedTop-1 IDVerification EER
183.4%9.6%
592.4%7.4%
1093.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.

ROC curve for verification, cross-day, 5 beats, held-out people. AUC 0.986, EER point marked at 0.084
ROC for the same setting: enrol on one day, verify on another, 5 beats, held-out people. AUC 0.986.

The watch

Finger on bezelabout 8 s Health Sensor SDKECG, 500 Hz Kotlin DSPfilter + R-peaks ONNX Runtimeembedder.onnx Rosterby initials Best match>= 0.487 or none Enrol: the same path, then the template is stored under the initials typed on the watch. Everything was meant to run on the watch.
Diagram. The Wear OS app as I built it, from fingertip to name. I never got a working result out of it on the watch.

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 tried the app on an actual Galaxy Watch and never got a working result. The trouble was a mix of the watch's operating system and my own code, and it is a slow thing to debug: connect the watch over ADB, try, repeat. I have no accuracy numbers from the watch, and I'm not going to claim any.

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 would only matter once the app runs, and I haven't shown that it closes the gap. I haven't published the app.

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. It is clearly worse. 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 didn't work, and the caveats

The watch itself. I never got it working: there were issues with the watch's operating system and with my code, and each attempt meant connecting over ADB, trying and repeating. Past that, there is a domain gap waiting: 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. I don't have a fix. A larger multi-day cohort is what would show how bad it 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 is weak 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.

What's unfinished

First, get the app to run on the watch and return 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. None of that is done.

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, but never got a working result on the watch
Credit
ArcFace (loss) and Pan-Tompkins (R-peak detection) are published methods. Libraries and datasets belong to their authors.
Code
Not public

← All work