A practical guide for teams building personal health analytics

Between the Log and the Answer

Nine ways health tracking apps get the math wrong, and how to check yours.

Three ways to read this
2 minJust the nineRead the summary and the nine cards below. That's the whole argument.
10 minThe gist of eachScroll through. Every problem opens with a short explanation and a real example.
35 minEverythingHit “Full detail” on any problem for the fix, the trade-offs, and how to test it.
Executive summary

Apps are giving people confident, specific, wrong health advice, and the statistics are rarely to blame.

The problem. Apps that find health patterns in self-tracked data are producing findings that aren't real. Not occasionally, but systematically, and in a consistent direction.

Why now. Millions of people track symptoms, food, sleep and medication daily, and a growing number of apps promise to tell them what's driving how they feel. Analyzing one person's data every night now costs pennies a year. The barrier to building this has vanished, so a lot of it is being built, largely without anyone checking whether the answers hold up.

What's going wrong. The errors sit in the unglamorous steps that turn log entries into a table a model can read: filling in blanks, choosing time zones, grouping into days, deciding what counts as a possible cause. Each step has an obvious default. Most of the obvious defaults are wrong, and each pushes results in a predictable direction, so you don't get random noise, you get consistent, plausible, backwards answers.

Key findings. Nine decisions account for most of the damage. Filling missing entries with zero makes every under-logged habit look protective, because people log less when they feel worse. Cutting a day short at the symptom, but not on symptom-free days, invents protective effects for anything done in the evening. And averaging an effect across all days, including ones the behavior never happened, shrinks real findings to nothing and convinces teams their data has no signal in it.

What to do. Fix the nine decisions: most are a few lines of code, and several change whether a result points the right way. Then build six automated tests that measure your own error rate, so “we think it works” becomes a number you track.

Who it's for. Engineers, data scientists and product managers building personal health analytics. Also useful to clinicians and investors judging whether a product's claims hold up.

The nine, at a glance

Every one of these looks like plumbing. Every one changes what a user is told.

Where this comes from

One example, start to finish

A health tracking app tells a user: “Eating breakfast reduces your migraines by 40%.”

The user believes it. They start forcing down breakfast on days they feel terrible. Nothing improves.

The finding was never real. This person doesn't log breakfast when they have a migraine, because they're lying in a dark room. The app filled the blank with a zero, saw thirty days of “no breakfast, migraine,” and drew the obvious conclusion.

The statistics were fine. The regression was correctly implemented. The bug was three steps earlier, in a line of code that filled empty cells with zero, a line nobody would flag in review, because it looks like data cleaning.

Why nobody catches this

Two groups of people know things about this problem, and neither sees the whole of it.

Researchers who study one person over time work with data collected on a protocol. Fixed schedule. Defined windows. Missed entries recorded as missed. Almost none of these problems come up, so almost nothing is written about them.

Engineers building the app see a data pipeline. Group the rows, fill the blanks, aggregate, hand it to the model. Every step looks like ordinary plumbing. Nothing signals that filling a blank is a decision that will change what the app tells someone about their health.

So the mistakes get made by competent people doing reasonable work, and nobody catches them, because catching them means standing in both places at once.

Where the errors actually live
Every step between a tap and an answer is a decision. Nine of them have a wrong default.
WHERE THE ERRORS ARE Log entry “coffee, 8:15” 1blanks 2time zone 3grouping 4ordering 5windows 6naming 8thresholds building the table The model rarely the problem What the user is told 7which number 9how sure
The model is almost never where it breaks. Seven of the nine problems happen before the data reaches it, and two happen after, when results are turned into a sentence a person reads.
The detail

The nine problems, in detail

01

Empty boxes are not zeros

Someone didn't log lunch on Tuesday. Most teams put a zero there. It's what fillna(0) does. But an empty cell can mean “didn't do it” or “did it and didn't write it down,” and those two aren't interchangeable.

The core fact

People log less when they feel worse.

So bad days have fewer entries. Fill those blanks with zero and bad days become “low everything” days. The model sees bad day, low exposure and concludes this protects you.

The app says
What's actually true
“Breakfast reduces your migraines by 40%”
You don't log breakfast when you have a migraine
“Drinking water reduces your joint pain”
You don't fill in the water tracker on painful days
“Walking protects against your fatigue”
You log walks on days you felt well enough to walk
The fixAdd one column to every model: how many entries the person made that day.
Full detail

An empty cell means one of three different things: they genuinely didn't do it; they did it and didn't record it; or the question doesn't apply to them. Treating all three as zero would be harmless if the second happened at random. It doesn't.

That people log less when they feel worse isn't a theory. It's the consistent finding across decades of research on self-reporting, and you can see it in your own data in an afternoon: count each person's daily entries, compare against how they rated that day, and watch the line slope down.

The scale of it

Every single thing a person stops logging on bad days will show up as protective. All of them, in the same direction, invisibly. And it's worst for the things that take effort to record, which in most apps means food, the exact thing users most want answers about.

What to do instead

Cheapest fix, do this today. Add one more column to every model: how many entries the person made that day. Now the model has somewhere to put the “how much did they log” effect instead of smearing it across everything else.

This is also an instant test. Any “protective” finding that disappears when you add this column was never about the behavior. It was about when the person felt like typing.

Better. Change the schema so you can tell “didn't happen” from “didn't say.” A single end-of-day tap (“nothing else to log today”) converts most ambiguous blanks into real zeros. Two seconds of user effort, worth more than any extra tracking field you could add.

Best. If you can predict who logs and when, you can correct for it statistically. Real work, and only worth doing once the two cheap fixes are in.

How to check yours

Regress daily entry count against same-day symptom severity, per person. A downward slope confirms you have this. Then rerun every model with and without the entry-count column, and count how many findings change sign or vanish. That count is your exposure.

02

A day starts where the person lives

UTC is the right answer to almost every other timestamp question in engineering. Here it's wrong. Bodies run on local time, and a UTC day boundary cuts through the middle of the night for most of the world, splitting one night's sleep across two rows, and filing last night's dinner under a different day than this morning's headache.

Most teams work that out. The trap is the repair: looking up the person's offset and adding it to every timestamp. That breaks in two ways that are easy to ship and hard to spot.

The fixStore the time zone name, not an offset. Convert per timestamp, at the moment you group.
Full detail

The two ways the repair breaks

  • Using today's offset on old data. Look it up once, apply it to their whole history, and every daylight-saving change shifts their entire past by an hour. Anything you conclude about timing across that boundary is quietly wrong.
  • Writing a shifted time and labeling it UTC. A local wall-clock time with a Z on the end isn't a real timestamp. It works fine until a second system reads it, and then it doesn't.

What to do instead

Store timestamps in UTC. Store the person's time zone name (like America/Denver), not a fixed offset. Convert to local time at the moment you group, one timestamp at a time, using a library that knows what the offset was on that specific date. If you need to move a local time between systems, send the local time and the zone name as separate fields. Never a shifted instant pretending to be UTC.

How to check yours

Take someone whose data spans a daylight-saving change. Plot what hours of the day they log, four weeks before and four weeks after. People don't change their habits by exactly one hour overnight. If the chart jumps, you have the bug.

03

A calendar day is the wrong box

Someone gets a headache at 9am Tuesday. The most likely cause is Monday evening, but calendar grouping puts those in two different rows and cuts the connection. Meanwhile everything they eat Tuesday afternoon, which cannot possibly have caused a 9am headache, lands in the same row and waters it down.

The default manages to exclude the real causes and include the impossible ones. And it forces one box size onto every outcome, when different things in the body move at wildly different speeds.

The fixBuild the window around the symptom, and let its length depend on what you're tracking.
One box size cannot fit every outcome
How long different things take to respond, on a log scale
1h3h6h 12h24h2d5d 3-hour bucket 24-hour bucket Blood sugar after a meal: 1-3 hours Migraine after a trigger: 4-24 hours Gut symptoms after a food: 6-48 hours Mood or energy after bad sleep: 24-72 hours Inflammatory flare: 24-120 hours Blood sugarMigraineGut symptoms Mood / energyInflammatory flare 1–3h4–24h6–48h 1–3d1–5d
Read the dashed lines. A 24-hour bucket starts after blood sugar has finished responding: it flattens the whole signal into nothing. A 3-hour bucket ends before a migraine, a gut symptom or a flare has begun.
Full detail

What to do instead

Build the box around the symptom, not the calendar. Every time the symptom happens, look backwards a fixed amount of time and gather up what happened in that stretch. Days with no symptom get regular fixed-length boxes, to give the model something to compare against.

Make the look-back length depend on what you're tracking. Hours for blood sugar. A day for headaches and gut symptoms. Several days for flares. This isn't a setting to tune. It's a fact about the body, and it should be written down with the reason next to it.

How to check yours

Run everything again with the window half as long, then twice as long. A real effect gets weaker as you move away from the right window, but it's still there. Something that shows up at exactly one window length and vanishes on both sides was never real.

04

Causes come before effects

This one produces the most embarrassing findings in the category, because people take medicine after they get sick. Most pipelines never ask whether something logged after the symptom should count as a possible cause.

The app says
What's actually true
“Antihistamines are linked to your rashes”
You take antihistamines because of the rash
“Painkillers are linked to your headaches”
Obviously
“Resting is linked to your fatigue”
You rest because you're exhausted

Users spot this instantly, and it's the fastest way to destroy their trust, because it reveals that the app doesn't understand the order things happened in.

And the part almost everyone misses

Once you drop everything logged after the symptom, you've cut symptom days short, but not normal days. Now an evening habit is uncounted on symptom days and fully counted on normal days. The app reports it as protective, and the effect is entirely manufactured by your own cutoff rule.

The fixDrop anything logged after the symptom, then cut the normal days short too.
Full detail

Applying the filter

For each symptom, throw out anything logged after it. Two details matter in practice.

Let whole-day numbers back in. Total sleep, morning check-ins, and anything else describing the whole day but carrying one timestamp will get thrown out for any morning symptom. Give them a start-of-day timestamp and let them through on purpose.

Decide what to do about repeats. If the symptom happened three times, cutting at the last one is the safe choice.

The asymmetry, in full

Say someone drinks wine in the evening. On headache days, cut off at 10am, the wine isn't counted. On good days the whole day is counted, so it is. The model sees less wine on headache days than on good days and reports that wine protects against headaches. There is nothing in the data.

The worse the mismatch (an evening habit and a morning symptom), the bigger the fake effect, easily large enough to be the main thing the app tells the user.

The fix

Cut the normal days short too. Either chop everyone at the person's typical symptom hour, or pick a random cutoff hour for each normal day drawn from the hours their symptoms actually happen. Now both kinds of day cover the same slice of time.

How to check yours

For each thing you track, work out what time of day people usually log it, and how much of it falls after the cutoff on symptom days. Put that number on a dashboard. Anything with a high number and a protective-looking result is a suspect. This takes an afternoon and will probably explain several results that have puzzled you for months.

05

“Today” and “this week” shouldn't overlap

Some things act immediately, some build up. So teams add a rolling total alongside today's amount. If that rolling window includes today, the two columns carry the same information twice.

The model can't tell them apart. Coefficients go unstable, flipping sign from one night to the next on data that barely changed. And because it looks like ordinary noise, everyone assumes they need more data. They don't. They need to fix two lines.

The fixShift the rolling window back one day so the two never overlap.
Full detail

The natural way to write it is:

today       = amount today
last 3 days = rolling sum over the last 3 days

Move the window back one day so the two don't overlap:

today        = amount today
prior 3 days = sum over the three days before today

Now they're independent and each means something on its own. You also get something genuinely useful: comparing the two tells you whether something is an immediate trigger or a build-up. Those need completely different advice. You avoid a trigger in the moment; you manage a build-up by cutting down over time.

One warning. Every extra time window multiplies how many things you're testing. Three windows across two hundred tracked items is six hundred tests per symptom per night. Windows aren't free. See #9.

How to check yours

Correlate each today/rolling pair. Above about 0.7 and they're overlapping. Then check whether those coefficients flip sign between nightly runs: that's the tell.

06

Five spellings of the same pill

“advil”, “Advil”, “advil 200”, “ibuprofen”, “ibuprofen 200mg”. One pill, five columns. Say they took it twelve times across those spellings, and you require three appearances to analyze something. None of the five clear it. The pill gets dropped entirely.

What just happened

The signal was in the data. Your naming layer destroyed it. The model never sees it, the user never hears about it, and nothing in your output says anything went missing.

This looks like a design choice. It's a math choice, and getting it wrong throws away data nothing downstream can recover.

The fixA fixed list with alias matching, and category depth that adapts to each user.
Full detail

Use a fixed list with alias matching, organized into categories. Yes, it adds friction. It's worth it.

And here's the part nobody talks about. Those categories aren't just tidiness. They're a dial, and the right setting is different for every single user.

Take dairy → cheese → aged cheese → parmesan. Each step down is more useful to know and happens less often.

  • Someone who logs everything for two years: parmesan might appear 40 times. Analyzable, and far more useful than “dairy.”
  • Someone who logs occasionally: parmesan appears twice and gets dropped. But dairy appears 60 times and works fine.
The fix

Set the level per person: use the most specific category their own data can support.

Light users get answers about food groups. Heavy users get answers about specific foods. Everyone gets something, and nobody gets a claim more precise than their data can back.

Two bonuses. You can show this as progress (“keep logging and we can go from 'dairy' to specific cheeses”), a rare case where what's good for the math is also good for engagement. And when “dairy” shows up as a result, you know exactly what to ask them to log more precisely next.

How to check yours

Count distinct typed strings per user, then see how many would collapse together with basic normalizing. A high collapse rate means you're fragmenting right now. Then work out what share of each person's logged items sits in columns too sparse to analyze. In free-text apps that's often most of them.

07

Stop averaging over days it didn't happen

Someone drinks wine on 20 days out of 400. Average the effect across all 400 days and you've included 380 days where the comparison is “no wine versus no wine.” A real effect present on those 20 days gets divided by twenty and reported as basically nil.

The characteristic failure

Everything comes out tiny, nothing looks actionable, and the reasonable conclusion is that health data is just noisy. It isn't. You diluted it.

The fixOnly compare days they actually did it, and take the typical dose from those days too.
Why real effects come out as nothing
One person, 400 tracked days. They drank wine on 20 of them.
20 days that answer the question 380 days that don't
380 of those comparisons are “no wine versus no wine.” This is why careful teams conclude their data has no signal in it: the signal was there, and it got averaged away against days the question never applied.
Full detail

A raw coefficient is a change in log-odds. Nobody outside a statistics department can read that, and showing it is giving up. The averaging fix is right in spirit and usually wrong in practice.

There's a second error stacked on top. “Typical amount” usually gets computed across all days, which answers “how much wine does this person drink per day on average?” That's a consumption rate, not a serving. Comparing against it produces a scenario nobody cares about.

The real question

On the days I actually do this, how much does it change things?

So work it out that way:

  1. Keep only the days they actually did it.
  2. Work out the typical amount from those days only: the serving when they have one, not the daily average.
  3. Predict twice on those days: once with it set to zero, once at the typical amount.
  4. Report the difference.

Now you have a percentage-point change, on days the thing happens, at the amount they actually use. Readable, honest, and it doesn't dilute.

Cap the extremes. Small-sample models occasionally spit out absurd numbers. Squash or cap what you display so one glitch can't dominate the list. Be clear with yourself that this is display hygiene, not a fix.

How to check yours

Work out every finding both ways: across all days, and across active days only. If you're using the all-days version, the gap between them is how much real signal you've been hiding. It's usually a factor of five to fifty.

08

Sometimes the honest answer is “not yet”

Below a certain amount of data, these models don't give you a weak answer. They give you an arbitrary one. With few data points the math systematically overstates effects, and in one specific situation it breaks down entirely and returns an enormous number that a naive display renders as your strongest finding ever.

Products get built to fill space, so empty screens feel like failure. They shouldn't. Saying nothing is a real, designed outcome.

The fixThree thresholds: enough time and enough active days, enough symptom occurrences, enough of the thing.
Full detail

Enough time, and enough days. Require both a minimum stretch of calendar time and a minimum number of days they actually logged, and require them together. Time alone lets through the person who logged twice a month for a year. Day count alone lets through the person who logged intensively for two weeks and stopped. Neither can support a reliable answer. Only requiring both keeps them both out.

Enough of the symptom. You need enough occurrences to model, and the floor should rise as you add more things to test.

Enough of the thing you're testing. Something that happened once can't be told apart from something that never varies.

A trap hiding in that last one

If you build rolling-total columns (#5), one single occurrence spreads forward into several days of the rolling column. So something that genuinely happened once produces a rolling column that appears to have happened three times, and sails straight past your threshold while carrying one day of actual information.

Always check the count on the original column, never the rolling one. Two lines of code. Nearly impossible to spot from the output, because the resulting finding looks completely normal.

On what you output. When you skip something, say so and say why, rather than just leaving it out. Anything reading your results needs to tell apart “we looked and found nothing,” “not enough data yet,” and “we didn't look.” If all it gets is a list of findings, it will show users “no problems found” when the truth is “we haven't got enough to say.”

How to check yours

Report how many of your users clear each threshold, and how many of their symptoms clear it. If nearly everyone gets through, your thresholds are too loose. If almost nobody does, the thing to fix is how you collect data, not the math.

09

Running the same test nightly is not one test

Two problems, either of which alone breaks the number you're showing.

First: you picked which variables to test using the same data you're testing them on. A p-value assumes you decided beforehand. So a “0.03” here does not mean a 3% chance of being a fluke. It's a ranking score, not a false-positive rate.

Second: textbook corrections assume you run the analysis once. You run it every night, for as long as someone uses the app. Two years, thirty symptoms, three findings each: roughly 65,000 reported associations for one person.

Here's what matters

A finding that showed up once and never again looks identical on screen to one that has shown up every single night for eight months. The second is vastly more trustworthy. Your system works out that difference every night, and then throws it away, by overwriting yesterday's results with today's.

The fixKeep every night's results and rank by how often a finding comes back.
How often a finding comes back tells you whether to trust it
Of findings that have survived N nights in a row, what share survive the next night?
100%75%50% 25%0 1235 8132134 Nights survived so far Healthy system: 97% Pure noise: flat at ~25%
Healthy system Pure noise
The shape is the whole point. A rising line means persistence carries information. A flat line means your findings are noise, and no amount of statistical sophistication elsewhere will change that.
Full detail

Why the nightly runs matter

Those runs aren't independent: last night and tonight share almost all their data, so it isn't really 65,000 separate tests. But it isn't one test either. The real number sits somewhere in between, it grows the longer someone uses your app, and no standard correction addresses it, because no normal research setting produces it.

What to do instead

1. Change the label. Stop calling it a p-value in your schemas, docs, and UI. Call it a strength score. Costs nothing, removes a false claim.

2. Correct within each run. Apply a false discovery rate correction across everything you tested that night. The goal is a shortlist where most entries are real, not a guarantee that no single one is wrong.

3. Rank by how often it comes back. If you take one thing from this guide, take this.

The recommendation

Keep every night's results. Rank findings first by how consistently they survive night after night as data accumulates, and only then by how strong they look tonight.

The reasoning is simple. A fluke happens when the noise lines up by chance, and it doesn't line up again tomorrow. A real effect holds, or gets stronger, as evidence piles up. So how often something comes back is a direct measurement of how much to trust it.

And unlike a p-value, it doesn't care what your screening step did, because it doesn't assume anything about the math at all. It just counts.

It's also nearly free. Your system already produces the raw material every night and deletes it. Keeping a history table and running a count is a schema change and a query.

How to check yours

Keep results for sixty days. Then group findings by how many nights in a row they've survived, and ask what share of each group survives the next night. That's the chart above, drawn from your own data.

The structural problem

The new user problem

Results get more reliable the more data someone has. Your thresholds let people in the moment they scrape past the minimum. Put those two facts together:

The inversion

The very first results a person ever sees are the least reliable results your app will ever show them.

They're produced from the smallest amount of data you accept, using math that overstates effects when data is thin. And they land at the exact moment the person is deciding whether to trust your app at all.

Trust gets set before reliability arrives
Two curves running in opposite directions over a user's first year
DANGER ZONE low reliability, high first impressions highlow month 036912 How reliable results are Users forming a first impression they cross around month 2–3
How reliable results are Users forming a first impression
Everything to the left of the crossing is where users get hurt. Someone acts on an early result, it doesn't hold up, and they correctly conclude the app doesn't work.
Four things that help

Use math built for small samples. Swap standard logistic regression for a penalized version. Firth's method is the standard one. It corrects the overstatement, and returns sensible numbers in the exact situation that breaks the normal approach. In most statistics libraries it's close to a one-line change, and it improves precisely the users your thresholds just let in. Probably the best return per line of code in this entire guide.

Label how sure you are, visibly. Don't show everything with the same confidence.

Confidence labels users can actually read
LabelWhat it meansWhat to say
Early signalShowed up once, new user“Keep logging to confirm this”
ConsistentHas held up across many nights“This keeps showing up in your data”
ConfirmedTested properly (see below)“You tested this and it held”

This turns your biggest liability into visible progress. People watch findings earn their status.

Ask for more before the first one. Consider requiring more evidence for someone's first result than their fiftieth. The first sets how they read everything after it.

Use the empty state. Someone who hasn't hit the threshold should be told what you're collecting and why, not shown a blank screen. It's the one place where being honest costs nothing and buys patience.

Implementation

Six tests you can build this month

Everything above takes judgment. This part doesn't. These are six tests you can build, run automatically, and turn into a number you track over time.

The way to think about it

Your pipeline is a measuring instrument, and instruments need calibrating. Nobody would trust a scale that had never been checked against a known weight. Most apps in this space have never been checked against a known nothing.

01

Shuffle the timestamps

Randomly shuffle the timestamps on a real user's data. This destroys any real relationship while keeping everything else identical. Run the whole pipeline on it, a few hundred times. Count what it finds. There should be almost nothing.

This one test is worth more than any amount of theorizing, because it measures what your actual system actually does when there is definitely nothing there. It also hands you something directly useful: now you know what score a fluke typically produces, so you can report each real finding as “stronger than 97% of pure noise”, a number you can honestly put in front of a user.

If you build one thing from this guide, build this.

02

Plant fake variables

Add made-up tracked items that look exactly like real ones (same frequency, same clustering) but generated with no connection to anything. They should never show up. How often they do is a false-positive rate you can measure continuously, on live data.

Matching the pattern is what makes this work. A smooth random number is a weak test. One that mimics “logged in bursts, a few times a month, more at weekends” is a real one.

03

Add the logging-count column

Rerun everything with the entry-count column added (#1). Count what disappears and what flips sign. That's your exposure to the biggest bias in the category, in one number. Track it permanently, not once.

04

Toggle the cutoff rule

Rerun with the “causes come first” filter off, on, and on for normal days too (#4). Anything whose direction depends on which version you ran is an artifact.

05

Stretch and squeeze the window

Rerun at half and double the window (#3). Real effects fade gradually. Artifacts appear at one setting and vanish either side.

06

Track how often findings come back

Keep the history and plot survival rate against how many nights something has held (#9). Both a test and your ranking signal.

What this buys you

A team that can say “our shuffle test finds false results 2% of the time, our planted fakes show up 1.8% of the time, and findings that have held ten nights hold an eleventh 94% of the time” is in a completely different position from a team that can't, with users, with clinicians, with regulators, and internally when deciding what to build next.

These six don't prove anything causes anything, and they don't fix any of the nine. What they do is make your error rate measurable. You can't improve what you can't measure, and in this field being able to measure it is rare enough to be a real advantage.

Limits

What this can and cannot tell you

Some things can't be fixed by better math. You can only account for what you track. If someone drinks wine mainly at restaurants, and restaurant meals differ in a dozen ways they never log, then “wine” quietly absorbs all of it. No model recovers what was never recorded.

But comparing a person to themselves buys you something real. Everything about them that doesn't change (genetics, baseline health, where they live, their other conditions) is perfectly accounted for. Not approximately. What it leaves open is everything that does change over time.

What you actually have

A pattern, in this one person, that holds up when you respect the order events happened in, account for the other things they track, and keep re-checking it as more data arrives.

That's genuinely valuable. It is not proof of cause, and your wording should never suggest it is. “Worth testing” is accurate. “Causes” is not. The gap between those two words is where trust gets lost.

How to get past the ceiling

If you can narrow things down to a few strong candidates, you can then help someone test one properly. Pick something the person controls. Have them deliberately do it for a block of days, then deliberately not do it for a block, then repeat, with your app deciding the schedule, not them.

Why this changes everything

When a coin flip decides whether they drink wine this week, the wine is no longer tied to their social calendar. The restaurant problem disappears, along with every other hidden factor you never tracked.

This is a real experiment, and it supports a claim about cause that no amount of pattern-finding can.

The reframe

The pattern-finding isn't the product. It's how you work out what's worth testing.

Observational data is bad at proving cause and excellent at cheaply narrowing a huge list down to a few candidates. Nobody can run structured experiments on two hundred things. They can run three. Everything in this guide exists to pick the right three.

It also gives your confidence labels a real top rung: early signal → consistent → you tested it yourself.

Practical limits, all manageable. Only things the person controls can be tested. They'll know which block they're in, so expectation plays a part. Say so. Anything with lasting effects needs a gap between blocks. And you have to track whether they actually stuck to the schedule.

Reference

The checklist

A half-day review. Each row points back to the problem it comes from.

Open the 27-point checklist
A: Building the table
#CheckWhat it looks like when it's wrong§
1Do you tell “didn't happen” apart from “didn't log”?Lots of unexplained “protective” findings01
2Is a daily entry-count column in every model?Findings track logging habits, not behavior01
3Are timestamps converted per-event using the zone name?Logging hours jump by an hour at daylight saving02
4Are windows built around the symptom, not the calendar?Evening→morning links never show up03
5Does window length depend on what's being tracked?Fast-moving things show nothing03
6Do you drop anything logged after the symptom?Medicines “cause” the thing they treat04
7Do you cut normal days short too?Evening habits look protective04
8Do “today” and “rolling” windows overlap?Coefficients flip sign between nights05
9Is there a fixed list with alias matching?Users have hundreds of distinct typed strings06
10Does category depth adapt to how much someone logs?Light users get no findings at all06
B: Working out and showing the answer
#CheckWhat it looks like when it's wrong§
11Are effects averaged over active days only?Everything's tiny; “there's no signal”07
12Is “typical amount” from active days only?You're comparing against a daily rate07
13Are time and active-day thresholds both required?Occasional loggers get findings08
14Is the occurrence count checked on the original column?Findings built on a single logged event08
15Do you say when you skipped something, and why?“Nothing found” and “didn't look” are identical08
16Is the score labeled honestly, not as a p-value?Users read a ranking score as a certainty09
17Do you correct for multiple tests within each run?More symptoms tracked = more findings, always09
18Are findings ranked by how often they come back?One-off flukes look like solid patterns09
19Are you using small-sample math (Firth)?Wild numbers for brand-new usersNew user
20Are confidence levels shown in the UI?Everything looks equally certainNew user
C: Checking your own work
#CheckWhat it looks like when it's wrong§
21Shuffle test running and tracked?You don't know your false-positive rateTest 1
22Planted fake variables in production?No live measurement of false positivesTest 2
23Logging-count comparison tracked over time?Adherence bias unmeasuredTest 3
24Cutoff and window comparisons run?Can't tell artifacts from real findingsTests 4–5
25Survival curve maintained and reviewed?No evidence behind your confidence claimsTest 6
26Does your wording avoid claiming cause?“Causes” or “triggers” in the UILimits
27Can a user turn a finding into a real test?You've accepted the ceilingLimits
Next steps

Where to start

If this applies to something you're building, here's the order to do it in.

Week 1
Find out where you stand. Build the shuffle test (Test 1). Run it on twenty real users' data. Until you have that baseline, nothing else you change can be evaluated.
Week 2
The two cheap fixes. Add a daily entry-count column (#1) and check your rolling windows don't overlap with today (#5). Rerun the shuffle test and see what moved.
Week 3
The two that flip results backwards. Check whether you drop anything logged after the symptom, and whether you cut normal days short too (#4). Check whether your effect sizes average over days the behavior never happened (#7).
Week 4
Start keeping history. Add a results history table and begin recording every night's findings (#9). The sooner this starts collecting, the sooner you have the best trust signal available to you.
Then
Work down the checklist. Anything still open after that is a known gap rather than an unknown one.
Quick answers

Frequently asked questions

Why does filling blank entries with zero create false findings in health tracking apps?

An empty cell can mean the person didn’t do something, or that they did it and didn’t write it down, and those two aren’t interchangeable. People log less when they feel worse, so bad days have fewer entries. If those blanks become zeros, bad days turn into “low everything” days and every under-logged habit looks protective. The cheapest fix is to add one column to every model: how many entries the person made that day.

Should a health app group days by UTC or by the person’s time zone?

By the person’s local time. Bodies run on local time, and a UTC day boundary cuts through the middle of the night for most of the world, splitting one night’s sleep across two rows. Store timestamps in UTC along with the person’s time zone name (like America/Denver), not a fixed offset, and convert each timestamp to local time at the moment you group. Applying today’s offset to old data shifts someone’s entire past by an hour at every daylight-saving change.

Why is a calendar day the wrong window for symptom data?

Calendar grouping can cut a cause off from its effect. A headache at 9am Tuesday most likely has its cause on Monday evening, which lands in a different row, while Tuesday afternoon, which cannot have caused it, lands in the same row. Build the window around the symptom instead, looking back a fixed amount of time, and let the length depend on what you’re tracking: hours for blood sugar, a day for headaches and gut symptoms, several days for flares.

Why do painkillers appear to cause headaches in app data?

Because people take medicine after they get sick, and most pipelines never ask whether something logged after the symptom should count as a possible cause. Drop anything logged after the symptom, then cut normal days short too so both kinds of day cover the same slice of time. Otherwise an evening habit goes uncounted on symptom days and fully counted on normal days, and the app reports it as protective.

Can I trust a p-value from a health app that re-runs its analysis every night?

Not as a false-positive rate. The variables were picked using the same data they’re tested on, so a p-value of 0.03 isn’t a 3% chance of being a fluke; it’s a ranking score. And because the analysis runs every night, two years with thirty symptoms and three findings each adds up to roughly 65,000 reported associations for one person. A better trust signal is how often a finding comes back: a fluke doesn’t line up again tomorrow, while a real effect holds as evidence accumulates.

When should a health tracking app say “not yet” instead of showing a finding?

When the person hasn’t cleared minimum thresholds. Below a certain amount of data, these models don’t give a weak answer, they give an arbitrary one. The guide recommends three thresholds: enough calendar time and enough logged days (both required together), enough occurrences of the symptom, and enough occurrences of the thing being tested. Saying nothing is a designed outcome, and the empty state should tell the person what you’re collecting and why.

How do I test whether my health analytics pipeline is producing real findings?

Build six automated tests: shuffle the timestamps on real data and count what the pipeline still finds (there should be almost nothing); plant fake variables that look like real ones and measure how often they show up; rerun with the entry-count column added; toggle the “causes come first” cutoff rule; stretch and squeeze the time window; and track how often findings come back night after night. Together they turn “we think it works” into error rates you can track.

Can pattern-finding in self-tracked data prove that something causes a symptom?

No. It gives you a pattern in one person that holds up over time, which is worth testing but is not proof of cause, so the wording in your app should say “worth testing,” not “causes.” To get past that ceiling, help the person run a real experiment on a strong candidate: do it for a block of days, then deliberately don’t for a block, then repeat, with the app deciding the schedule rather than the person.

What is an N-of-1 study in health tracking?

It means studying one person over time and comparing them to themselves, rather than comparing different people. Everything about that person that doesn’t change, such as genetics, baseline health, where they live and their other conditions, is accounted for perfectly. What it leaves open is everything that does change over time and that the person never tracked.

In short

The plumbing matters more than the model

The methods for studying one person properly have been around for forty years. The computing to do it for everyone, every night, for pennies, now exists too. What sits between them isn't a modeling problem. It's a construction problem, spread across a dozen decisions that each look like plumbing and each push results in a particular direction.

The plumbing matters more than the model. A week auditing how you build your table will do more than a quarter improving your math. Several of the nine don't make results slightly worse. They flip them backwards.

How often something comes back beats how strong it looks. Your system already works this out nightly and deletes it. It needs no assumptions, can't be fooled by your screening step, and costs a table and a query.

Pattern-finding is how you choose what to test, not the answer itself. No amount of care turns observational data into proof. But it's excellent at narrowing two hundred possibilities to three, and a person who can't test two hundred things can easily test three.

The uncomfortable summary: most apps in this category are showing people results whose error rate has never been measured. That's fixable in a month, and the six tests above are how. Everything else here gets easier once that number exists, because for the first time you can see whether a change helped.

Appendix

Glossary

Open the glossary
TermWhat it means
CoefficientThe number a model produces for each thing it tested, showing how strongly it's linked to the outcome. Not readable by users as-is.
RegressionThe family of math used to work out which of many things is linked to an outcome, while accounting for the others.
Logistic regressionThe version used when the outcome is yes/no: did the headache happen or not.
p-valueMeant to express how likely a result is to be a fluke. Only means that if you decided what to test before looking, which these systems don't. See #9.
False discovery rateA correction applied when you test many things at once, so most of what you report is real.
Firth's methodA version of logistic regression built for small amounts of data. Corrects the tendency to overstate effects.
ConfoundingWhen something you didn't track is the real explanation. Wine looks harmful, but it's the restaurant meals that come with it.
Marginal effectThe plain-language answer: how much the probability changes if they do this versus don't.
N-of-1Studying one person over time and comparing them to themselves, rather than comparing different people.
Further reading
  1. Guyatt, G. et al. Determining optimal therapy: randomized trials in individual patients. New England Journal of Medicine, 1986.
  2. Lillie, E.O. et al. The n-of-1 clinical trial: the ultimate strategy for individualizing medicine? Personalized Medicine, 2011.
  3. Kravitz, R.L., Duan, N. (eds.). Design and Implementation of N-of-1 Trials: A User's Guide. Agency for Healthcare Research and Quality, 2014.
  4. Shiffman, S., Stone, A.A., Hufford, M.R. Ecological momentary assessment. Annual Review of Clinical Psychology, 2008.
  5. Benjamini, Y., Hochberg, Y. Controlling the false discovery rate. Journal of the Royal Statistical Society Series B, 1995.
  6. Firth, D. Bias reduction of maximum likelihood estimates. Biometrika, 1993.
  7. Heinze, G., Schemper, M. A solution to the problem of separation in logistic regression. Statistics in Medicine, 2002.
  8. Meinshausen, N., Bühlmann, P. Stability selection. Journal of the Royal Statistical Society Series B, 2010.
  9. Little, R.J.A., Rubin, D.B. Statistical Analysis with Missing Data, 3rd ed. Wiley, 2019.
  10. Hernán, M.A., Robins, J.M. Causal Inference: What If. Chapman & Hall/CRC, 2020.

This guide describes problems and fixes that apply to any app finding patterns in one person's self-tracked health data over time. It names no product or company.