The statistics a visualization of several shows beside its chart, in the Editor for Several Trackables. Their job is to let someone learn about themselves, and the one thing they must never do is call seven days of noise a strong relationship. So every verdict is gated on significance, never on how big a number looks, and each family of comparisons is corrected as a family: the cells of a matrix, the lags of a scan, and the pairs a person looks at in one sitting. They say “here is a relationship”, never what caused it.
The tests
Three tests cover every mode, each reusing the machinery of the one before:
| question | test | its tail |
|---|---|---|
| do two numbers move together? | Pearson’s r, with a two-tailed p from Student’s t and a 95% interval through Fisher’s z | the incomplete beta |
| do a number’s means differ across groups (weekdays, months, a scale’s values)? | one-way ANOVA, F and its p | the incomplete beta |
| are two scales independent? | Fisher’s exact test when each has two levels; otherwise the chi-square test of independence, trusted only when every pairing expects at least MIN_EXPECTED (5) days; Cramér’s V for its size | log Γ for Fisher (fisherExactP in core’s stats.ts), the incomplete gamma for chi-square |
The special functions, a Lanczos lgamma, the regularized incomplete beta (betainc) and gamma (gammainc), tDistP, pearsonP, pearsonCI, chiSquareP and bhAdjust, are in packages/core/src/stats.ts, pure and free of any domain. stats.test.ts checks them against closed forms, published critical values and R’s p.adjust, not against numbers they produced themselves.
Beside Pearson the engine (apps/renderer/src/components/series/engine.ts) computes Spearman’s rank correlation, which is Pearson on average-ranked values, so a relationship that rises steadily but not in a straight line shows its strength and outliers stop dragging it; its p reuses the same t-test, a good approximation from about ten points. The least-squares regression gives the slope, intercept, R² and RMSE; on ranks those mean nothing, so only Spearman’s r and p are shown. Same-day, lagged and rank correlations all run through one function, statsOf.
Simple and Advanced
The Statistics panel opens on Simple: r with its badge, and one sentence, “When Water goes up, Weight tends to go down. Over 41 days, this looks real”, or “Over 12 shared days this could still be chance — keep logging both”, or, under three shared days, that there are too few to tell. Any partial correlation and the session’s correction show there too. Advanced adds r’s 95% interval, Spearman, the lag scan, the regression, the sample (n and p) and the matrix. A grouping shows its highest group with a sentence, then its sample in Simple, or the ANOVA and each group’s mean in Advanced; a heatmap shows its association (Cramér’s V, with a sentence saying whether the two are related or look independent) and the days both scales were logged, and in Advanced the chi-square.
Significance before size
strength(r, p) names a correlation, and it takes the p that judges it because r alone cannot tell a relationship from noise: four points at r = 0.8 have p ≈ 0.2, a coin flip. It reads “too few points” where there is no p, under three points (two points always give r = ±1, a line rather than evidence), “not significant” at p ≥ 0.05, and only then a size with its direction: strong from |r| = 0.6, moderate from 0.3, weak from 0.1, otherwise none. Significance and size stay separate questions: over a long history an r of 0.02 can be real and still negligible, and it reads “none”. When the verdict is not significant, r is dimmed and the badge takes its neutral tone; a significant one is coloured, the strong ones as a success. A heatmap’s Cramér’s V is lit the same way, only when its chi-square beats chance.
Two scales are judged only where the test holds: contingency in components/series/engine.ts takes Fisher’s exact test for a 2×2 table and chi-square for a larger one once every pairing expects at least MIN_EXPECTED days, and otherwise gives no p. contingencyWords says which test judged it and over how many days, or that there are too few days to tell and why. ANOVA still assumes each group’s values are independent, and that is not enforced, so a strongly autocorrelated series can still light up. The corrections below guard against fishing, not against a badly conditioned test.
The matrix
A matrix of k series is k(k−1)/2 tests at once: twenty series are 190, and about nine cells would clear α = 0.05 by chance. Colour is what the eye reads first, so colour follows the corrected evidence rather than |r|. correlationMatrix gives each cell r, n and a q adjusted by Benjamini–Hochberg across the unique pairs, the upper triangle only, since the matrix is symmetric and counting both halves would double the family and over-correct. The diagonal is not a hypothesis and has no q. A cell is coloured only where its q is under 0.05 (cellShows), and otherwise drawn without colour and with its number dimmed; its tooltip gives r, n and q. A cell over fewer than three shared days has no p and so no q, and is no evidence: two shared days always give r = ±1, so it is drawn inert and its tooltip says too few to tell.
Benjamini–Hochberg controls the false discovery rate rather than the family-wise error Bonferroni controls: this screen surfaces candidates to look at harder, and Bonferroni is strict enough to hide everything real in one person’s data. Of the cells it lights up, about one in twenty are expected to be flukes. The matrix pairs series on the same day, whatever the lag.
Lag
Same-day is rarely the question self-tracking asks: does today’s water go with weight two days on? A lag shifts the second series before pairing, pairsAtLag(a, b, lag) pairing a on day t with b on day t + lag, so a positive lag means the first series leads. The rail’s stepper sets it, from −14 to +14 days, while days are days, and it is saved with the visualization.
Scanning every lag and reporting the best is the matrix’s trap in another shape: of 29 lags of pure noise, one clears α = 0.05 by chance. lagSweep computes r at every lag and adjusts across the whole scan, and bestLag names a peak only where its q is under 0.05. The scan is drawn as bars, height by |r| and colour by sign, the survivors bright and the rest faint, and read out as “Peaks at +2d (r 0.41) — survives correction across 29 lags” or “No lag stands out across 29 scanned. Exploratory: a peak here is a hypothesis to test, not a finding.”
Clicking a bar picks that lag. A lag picked from the scan is judged by its q, not its raw p (significantAtLag), because picking the winner of a scan is the comparison the scan already corrects for; lag 0 is judged by its own p, since nothing was scanned to reach it. That gate’s p (pAtLag) sets r’s colour, the badge’s tone, the sentence and the badge’s words alike, so the badge never says “moderate +” beside “this could still be chance”. Spearman and a partial correlation at a picked lag are computed at that lag and have no scan of their own to correct against, so they are exploratory there.
A session
Every correction above lives inside one chart. None watches the person who adds a series, reads r, swaps it and reads again: twenty pairs by eye, one of which clears α = 0.05 by chance, without ever looking like many tests. So the editor remembers each distinct pair compared in the session, with its lag-0 p, and adjusts the current pair’s p across them (sessionCorrect, the same bhAdjust). A pair is unordered (pairKey), since r(a, b) = r(b, a), and it is one hypothesis however long it is looked at, which is why its lag-0 p is the one kept: keeping the picked lag’s would let scanning for the best lag improve the session’s q.
Once two pairs or more have been compared, a significant verdict says how it stands: “…but it’s 1 of 6 pairs you’ve compared this session. Corrected for that, q = 0.12 — this one could be chance”, or “Survives correction for the 6 pairs compared this session”. The current pair counts from its first look. A “new question” link clears the session, for when one deliberate question is being asked. The session is not saved with the visualization: a saved visualization is that one deliberate question, and carrying the browsing that led to it would make it a different one.
Holding a series constant
Two series can move together only because both follow a third: water tracks weight, and both track the days you exercised. Control for, on a scatter, holds one or more of the series after the first two constant, removing each one’s linear effect from both. With one control, r(x, y · z) = (rxy − rxz·ryz) / √((1 − rxz²)(1 − ryz²)); with several, partialStatsMulti applies that formula recursively, peeling one control off at a time and memoizing each sub-partial on its pair and its sorted controls, with no matrix inversion. Each level is clamped to [−1, 1], because floating point can push a near-collinear partial just past 1, and the next level’s square root would then poison the whole tree.
Holding k series constant costs k degrees of freedom, so the same t-test and Fisher interval are reused at n − k: df = n − 2 − k and se = 1/√(n − k − 3), with no second statistics path. A partial needs n − k of at least 3, and is empty when a control is collinear with x, y or another control, since “with z removed” then has nothing left to ask, which is not the same as zero. It reports n, r, r², p and the interval only, because there is no single line to fit. It reads at the same lag as the scatter, x on day t, y at t + lag and every control on day t (controlColumns), so it describes the pairing on screen.
The readout names what is held (“Holding Water + Steps constant”), gives r, p and n, and says in words what happened to the relationship, since “r fell from 0.62 to 0.08” is a number and “most of that was Sleep” is an answer: it flips sign when the sign changes and the partial is beyond ±0.2, collapses when a raw r beyond ±0.3 falls within ±0.15, weakens when it loses more than 0.15, and otherwise holds up. Holding a mediator, a series that itself follows X, shows as the partial dropping one control at a time.
Groups and scales
A grouping’s buckets, weekdays, months or a scale partner’s values, are compared by their means and a one-way ANOVA (groupStatsFrom); with fewer than two non-empty groups or no spread within them there is nothing to test, and no F is given. A contingency table counts the days both scales were logged at each pair of values. Its degrees of freedom and Cramér’s V come from the values that occur in the data, not every declared one: an option nobody logged, live or retired, would otherwise add degrees of freedom and shrink V. The table itself keeps every declared value, so a gap in an ordered scale stays visible.
Related
Editor for Several Trackables · Visualization Persistence · Value Types