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.
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.
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.

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.
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.
Results
| 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 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.
| 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.

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