Spotting when I'm getting sick
I am writing this with a fever. Sore throat and a cough started on 6 November, the fever arrived on the 8th, and it is still here on the 9th.
On the second night of it I got curious and asked a question I had never asked my own health data: did you see this coming, and if so, when?
It did. By about two days. Nothing was watching.
What the numbers actually did
Here are the seven nights around onset. z is standard deviations from my own rolling baseline, and for HRV and SpO2 I have flipped the sign so that positive always means "worse".
The same nights as numbers:
night resting HR HRV resp rate skin temp sleep z_resp z_rhr z_hrv
bpm ms breaths/m °C dev h
-------------------------------------------------------------------------------------
Nov 03 55 51.7 15.6 -0.46 7.65 -0.03 1.26 -0.51
Nov 04 55 41.7 17.2 +0.28 7.98 +2.38 1.19 0.43
Nov 05 55 28.4 17.0 -0.34 7.48 +1.77 1.10 1.62
Nov 06 52 63.9 15.8 -0.88 7.32 +0.09 0.05 -1.55 <- symptoms start
Nov 07 57 48.2 15.6 +0.14 6.32 -0.20 1.64 -0.05
Nov 08 63 18.4 17.4 +0.89 4.88 +2.14 3.37 2.54 <- fever
Nov 09 61 28.4 17.8 -0.65 6.18 +2.32 2.09 1.37
Respiratory rate is the only thing that moved early. It went to +2.4 SD on 4 November and stayed at +1.8 SD on the 5th. Symptoms turned up on the 6th. That is two nights of warning from a single channel.
Resting heart rate and HRV did nothing useful in that window. They broke out on the night of the 8th, resting HR at +3.4 SD and HRV collapsed to 18.4 ms, which is the fever night. By then I did not need a wearable to tell me I was ill. A signal that fires once you already feel terrible is not an early warning, it is a thermometer.
Two more things that did not work, and I want them on the record because the failures are more instructive than the hit:
Skin temperature inverted. Fitbit's temperature deviation read +0.89 °C on the fever night, which looks like exactly the signal you would want. The very next night, still feverish, it read −0.65. One night positive, one night negative, same illness. I deliberately left it out of the alarm rule.
Activity was worse than useless. On 6 November, the day the sore throat started, I walked 12,555 steps and the activity score came back 100 out of 100. Today, flat in bed, it is 427 steps and a score of 18. The activity data describes what I did, not what was happening to me. On day zero, what I did was go about my life.
The rule, and why I am not calling it a model
The rule I ended up with is embarrassingly simple:
respiratory-rate z ≥ 1.2 on two consecutive scored nights
Across 127 scored nights it has fired exactly twice: 5 November, and today. The first was the day before symptoms. So on the single episode available, it is two for two.
And it is worth almost nothing as evidence, because I chose it knowing the answer.
This is the part where a post like this normally shows a ROC curve. I do not get one. I have one labelled illness. A threshold picked after looking at the episode it is supposed to catch is not a validated detector, it is a description of the episode wearing a detector's clothes. I would be fooling myself to call it anything else, and the honest thing to do with a rule like this is state the n and move on.
What I can say is that the looser versions are definitively bad. A single-night composite score ≥ 1.2, the kind of thing that sounds reasonable, fires on 13% of all nights. That is roughly one alarm every eight days, forever. Any alert I would actually keep has to be rarer than my illnesses, and most obvious variants are not.
The confound, and the variable I had all along
The broader "acute" rule in the same job has fired three times in those 127 nights. One is the fever. The other two are 30 June and 13 September.
Neither was an illness. 30 June was the night after a gig in Matera, an hour's drive each way, with a flight to Girona the next morning. 13 September was the night after a padel match and a gym session.
So the physiological signature of "I am fighting something" and the signature of "I thrashed myself yesterday" look close enough that a crude threshold cannot separate them. That is not a surprise in hindsight: elevated respiration, high resting HR and suppressed HRV are what recovery looks like too.
The missing variable was obvious and I already had it: the previous day's load. The calendar knows I had a concert. The padel table knows I played a match. So I wired all three sources in, and every scored night now carries what the day before it asked of me.
It separates the cases cleanly. 30 June reads Gig Dino. 13 September reads Gym 7; Padel match; sport 95min. All three fires in this illness read nothing at all: no gig, no training, no travel.
Two details were load-bearing. Walking had to be thrown out, because 721 of my roughly 1,100 workout rows are walking at a 24-minute average and that is me crossing Bari, not training. And the intensity field was no help whatsoever: it reads moderate or blank on everything, including a 95-minute match.
The thing I did not do is let it silence an alert. 64 of 127 nights follow a day with load on it, so "no load" is a coin flip, and three clean fires out of three could happen by chance one time in eight. That is a hint, not a filter. So the alert states it and leaves the judgement to me:
Day before: no gig, training or travel on record. Nothing to explain this away.
or, on the nights that would have fooled me:
Day before: Gym 7; Padel match; sport 95min. Recovery from that looks like this too, so weigh the alert down.
Which is the whole trick with a dataset this small. The covariate is not strong enough to make a decision for me, but it is strong enough to stop me having to reconstruct last Tuesday from memory while running a temperature.
What I actually built
Not a predictor. A daily job that:
- scores last night against the rolling baseline and writes one row per night to a feature store
- evaluates the two rules and stays completely silent unless one fires
- sends a single message when one does, with a seven-day dedupe so one illness cannot generate seven alerts
- annotates that message with yesterday's gigs, training and travel, so the "or you just had a big day" question is answered in the alert itself
- and writes to a second table that did not exist before this week: an episode log, one row per illness, with onset date, symptoms, severity, and my best guess at exposure
That second table is the actual output of this whole exercise. It has exactly one row in it: this illness, onset 6 November, moderate, fever yes, suspected exposure a crowded indoor ceremony on the 4th, which is squarely in the incubation window.
One row is not a dataset. But it is the first one, and it exists so that the next time this happens I can ask whether the tripwire fired, instead of reconstructing the answer from scratch while running a temperature.
The bit worth generalising
The instinct, when you have years of health data and you get sick, is to go looking for the model. I went looking for the model. What I found was that I had four and a half months of the metrics that mattered, three weeks of baseline, and not a single label, and that the one genuinely useful signal was a channel I had never once looked at.
If you are sitting on wearable data and thinking about doing this: check which fields actually survived your device changes before you plan anything. Then start writing down when you feel ill, today, in whatever form you will keep up. The physiology is already being recorded for you. The labels are the part only you can produce, and without them the rest is just a nice-looking chart of a thing that already happened.
I will know whether any of this works the next time I catch something. Which, given that I apparently walk 12,000 steps on day one of an infection, will probably not take long.