Skip to content

Experiment Forensics

Re-analysing 22,743 real headline A/B tests from Upworthy, and a 64,000-customer email test, to see how much the analysis changes the answer. I wrote the plan down and locked it before opening the data that would test it. All 13 pre-registered checks replicated.

Case study8 min read

Simulated A/A tests at Upworthy's real click rates and sample sizes. There's nothing to find, yet checking a z-test after every batch finds a “winner” 30.6% of the time by the hundredth look.
Role
Solo project: question, analysis, code and write-up.
When
October 2026
Stack
Python, pandas, NumPy, SciPy, statsmodels, matplotlib, pytest, uv

The problem

An A/B test's data is fixed once it's collected, but its answer still depends on the analysis: when you look, how you handle noise, how many groups you check. How much does that matter on real experiments?

What I built

A small Python package and one command that re-analyse the Upworthy Research Archive and the Hillstrom email test: power, peeking, the winner's curse, variance reduction and subgroups. The plan was locked in git before the confirmation data was opened.

What happened

The typical test could only detect a 63% lift. Checking 100 times turns a 5% false-positive rate into 31%. “Significant” winners shrink by about 40% once noise is accounted for. All 13 pre-set checks replicated on the held-back data.

  • 22,743

    real A/B tests in the confirmation set

  • 30.6%

    false winners after 100 looks, against a nominal 5%

  • ~40%

    median shrinkage of a “significant” winner

  • 13 / 13

    pre-registered checks that replicated

01

Why re-analyse finished experiments

The usual A/B testing pitfalls (checking too early, tests too small to see anything, winners that don't hold up) are easy to describe. I wanted to measure each one on real experiments, at real sizes.

Two public datasets made that possible. The Upworthy Research Archive holds 32,487 headline tests the site ran between 2013 and 2015: editors wrote several headlines for a story, and readers were randomly shown one. Each row is one headline (an “arm”) with its impressions and clicks. The Hillstrom email test is one experiment with 64,000 customers, randomly sent a men's email, a women's email or nothing, with what each had spent in the year before. Upworthy is big enough to study power, peeking and the winner's curse. Hillstrom has the per-customer history that variance reduction needs.

The archive also comes split into separate releases, so that people can explore on one and confirm on another. That turned out to be the most useful thing about it.

02

Explore on one set, lock the plan, confirm on the other

Everything was built on the exploratory release: 4,873 tests. The confirmatory release, 22,743 tests, wasn't opened. When the exploratory analysis was done, I wrote down every definition, exclusion, random seed and threshold in a plan, along with 13 numbers to compare and the result each would need to count as replicating. That went into git. The plan's fingerprint went in as a second commit, and the code refuses to read the confirmation file unless that lock is there and still matches the plan.

Then the confirmatory analysis ran once. It also stopped me later. After the run, I tidied a chart's axes and noted the change in the plan, and the next run refused: the plan had changed after it was locked. So changes like that now go in a separate DEVIATIONS file, and the plan stays as it was when the data was opened.

The lock caught me editing the plan after the fact. That's what it's for.

03

First, throw out the tests that weren't random

Before any analysis, there's a known problem. A correction the archive's authors published in 2024 says tests from 25 June 2013 to 10 January 2014, 22% of the archive, weren't properly randomised, and should be treated that way. I dropped every test whose first headline was created in that window: 4,907 of the 22,743 confirmation tests (21.6%), leaving 17,836.

The data backs the correction up. If readers are split evenly, each headline in a test should get about the same number of impressions. Inside the window, 70.1% of tests fail that check at p < 0.001. Outside it, 0.5% do.

04

Real effects are small, and most tests couldn't see them

Each headline was compared with the one written first in its test. Upworthy had no control headline, and the first one is fixed before any reader sees it, so it can't be chosen for how it did. That gives 67,851 comparisons. The median arm got 3,111 impressions, and the median click rate was 1.21%.

Observed differences are a mix of real effect and luck. Separating the two (the method is in the winner's-curse section below) puts the typical true difference at 0.19 percentage points. The typical test needed a difference of 0.74 points, about a 63% relative lift, to detect it reliably (80% power). Only 0.08% of tests were big enough to detect the typical true effect, and average power against it was 13%.

So most of these tests could only catch a headline that was dramatically better, which most of the time there wasn't.

The typical test needed a 63% lift to see anything.
The smallest difference each test could reliably detect. The line is the typical true effect. Almost every test sits to the right of it.

05

Checking early

The tempting habit is to check a test as data comes in and stop when it looks significant. To measure what that costs, I simulated 20,000 A/A tests, where both arms are identical, using click rates and sample sizes drawn from real Upworthy tests, and checked a standard z-test after every batch of readers.

Checked once at the end, 4.9% of A/A tests come out “significant”, as they should. Checked 10 times, 18.7%. Checked 100 times, 30.6%. With nothing to find, almost one test in three finds something.

There are tests built to be checked continuously. I used one, mSPRT, and its false-positive rate stayed under 0.4% however often it was checked. But that safety costs power, and these tests had none to spare: for a headline that's truly 50% better, one check at the end finds it 53% of the time, and mSPRT checked 100 times only 13%.

Times checkedz-test false positivesmSPRT false positives
14.9%0.12%
1018.7%0.23%
10030.6%0.31%

20,000 simulated A/A tests at real Upworthy click rates and sizes, on the confirmation set. Nominal rate 5%. The chart at the top shows every number of looks from 1 to 100.

06

The winner's curse

Of the tests with a headline that beat the first one, 3,820 (21.4% of all tests) won at p < 0.05, with a median lift of 0.79 percentage points. Picking winners that way picks up luck along with real improvement, so a re-run would usually show less.

To estimate how much less, I used empirical Bayes: learn from all 67,851 comparisons what true effects tend to look like, then estimate each winner's true effect given that and its own noise. The median “significant” winner shrinks by about 40%. Fitting the distribution of true effects two different ways gives nearly the same answer: 40.6% with a flexible shape, 39.7% with a normal curve. Only 38.3% of the significant winners have at least a 95% chance of truly beating the first headline.

The extreme cases shrink the most. One test reports 8 clicks from 115 readers against 1 from 111: a 6-point lift, significant at p = 0.02. After shrinkage it's 0.07 points. A typical winner, the same headline with a different image, goes from 0.91 points to 0.54.

Observed lift against the estimated true lift. Anything below the dashed line shrank. The circled points are the two examples in the text.

07

An A/A test hiding in the data

While building the analysis, I noticed that some tests had two arms showing exactly the same headline, image and excerpt. That's an accidental A/A test, and in the confirmation set there are 2,011 such pairs. About 5% should differ at p < 0.05. 12.9% do.

So clicks vary between identical arms nearly twice as much as the standard model assumes (the average squared z-score is 1.92, where it should be 1). The data can't say why. It might be something about the arms that the archive doesn't record, or traffic that wasn't spread evenly over time. Either way, the standard errors everyone uses on data like this are probably too small, which makes every result above, if anything, too kind.

Accounting for it in the winner's-curse analysis raises the median shrinkage from about 40% to 88%, and only half the significant winners would still be significant. I report that as a sensitivity check rather than a headline, because I don't know the cause.

The data contained its own A/A test, and it failed.

08

Variance reduction that did nothing

CUPED is a standard trick: use what you knew about each customer before the experiment to explain away some of the noise, so the same data gives a tighter estimate. On Hillstrom, the obvious pre-period variable is what each customer spent in the past year.

It made almost no difference. The men's email raised spend by $0.77 per customer, and the confidence interval was $0.57 wide with or without CUPED: a 0.04% variance reduction. Using every pre-period variable, or regression adjustment, didn't help either. On visits, the gain was about 3%.

The reason is in the data. Only 0.9% of customers spent anything in the two weeks, and past-year spending explains 0.02% of the variance in what they spent. CUPED can only remove what the covariate predicts, and here it predicts almost nothing. It's a useful method, but only when the past predicts the outcome, and that's worth checking before counting on it.

Five estimators, four effects. The intervals are almost identical: past spending tells you very little about who buys in the next two weeks.

09

Who responds best? Mostly nobody in particular

The last question is the one every campaign review asks: which customers responded most? I split Hillstrom into 23 subgroups (by recency, past spend, channel, location, new customers and past purchase categories) and asked two separate questions.

Does the email work within each group? For spend, 31 of 46 tests say yes at p < 0.05, and 11 survive the strictest correction (Holm). Does it work differently in one group than in everyone else? That's the question a segment story needs, and it's much harder: across both outcomes, 5 of 80 tests pass at p < 0.05, and 2 survive correction. Both are about visits, and both make sense: the women's email works better for customers who had bought women's merchandise, and worse for those who had bought men's. Every story about spend by segment fails.

Effect on spend by subgroup. The green ones looked different from the rest at p < 0.05. None survive correction.

10

What replicated

All 13 pre-set checks met their criteria on the confirmation set, and most landed close to the exploratory value. Hillstrom is a single experiment with no second release, so its results stay exploratory.

CheckExploratoryConfirmatory
Tests in the broken window21.5%21.6%
Uneven split, inside the window / outside70.0% / 0.5%70.1% / 0.5%
Identical arms differing at p < 0.0513.3%12.9%
Tests powered for the typical effect0.03%0.08%
A/A false positives, 1 / 10 / 100 checks5.2 / 18.9 / 30.4%4.9 / 18.7 / 30.6%
mSPRT false positives, 100 checks0.32%0.31%
Median shrinkage of significant winners40.5%40.6%

Exploratory: 3,823 tests after the exclusion. Confirmatory: 17,836. The full table, with each criterion, is in the confirmatory report.

What I'd carry forward

  1. 1Before analysing an experiment, check that it was randomised. An uneven split is the cheapest test there is.
  2. 2Work out what a test can detect before running it. Most of these could only see effects that rarely exist.
  3. 3Decide when you'll look before you start, or use a test built for looking often.
  4. 4Expect a significant winner to do worse next time. The bigger the surprise, the more it shrinks.
  5. 5Variance reduction needs a covariate that predicts the outcome. Check before counting on it.
  6. 6Lock the plan before the data that tests it. Then the result can't bend to fit.

Get in touch

Questions about my work, a role, or a project idea. Send a message here or email me directly.

Srikarmks@gmail.com