A/B test
In short: An A/B test compares two variants on randomly split visitors and checks whether the gap in conversion is real or just noise.
Problem
“Is the new cart better?”
The team made a new cart page: shorter, with fewer fields. Nobody knows whether it is better. So for two weeks every visit to the site sees, at random, either the old (old) or the new (new) cart. The ordered column is 1 if the visit ends with an order:
This is an A/B test: two versions, visitors split at random, one measure. The measure here is the conversion: the share of visits that end with an order. The old cart has 3.10%, the new one 3.72%.
The gap is 0.62 percentage points: a difference between two percentages. That is about one fifth more orders. But is it noise?
The idea
Is the gap noise?
This is the question from the lesson on hypotheses. The null hypothesis: the cart makes no difference. If it is true, the labels old and new do not matter and you can shuffle them. How often do the shuffled data give a gap of at least 0.62 points?
In 2000 shuffles the largest gap is 0.76 points, but only 6 times is it at least as big as the real one. p is 0.003. A gap like this rarely comes from noise.
More precisely, if you are curious
ordered is 0 or 1, so the mean of the column is exactly the share of orders. The shuffling here is a loop: the table has 28,176 rows, and 2000 shuffles at once in one matrix would take a lot of memory in the browser.
Try it
On every device?
Does the lift come from phones or from desktops? Store in rates the conversion in percent for every device (rows) and variant (columns), and in lift the difference new − old for every device, in points.
Solution
import pandas as pd
visits = pd.read_csv('ab_visits.csv')
rates = visits.pivot_table(index='device', columns='variant', values='ordered', aggfunc='mean') * 100
lift = rates['new'] - rates['old']
print(rates.round(2))
print(lift.round(2))visits.pivot_table(index='device', columns='variant', values='ordered', aggfunc='mean') * 100 gives a 2 × 2 table. On desktop the conversion is 4.20% with the old cart and 5.26% with the new one, on mobile 2.51% and 2.90%. The lift is on both devices: 1.05 and 0.39 points. With the revenue column you would compute the revenue per visit, not the conversion.
The idea
The quicker way: chi2_contingency
For two shares there is a ready-made check. First pd.crosstab counts the pairs: how many visits of each variant have an order and how many do not. Then scipy.stats.chi2_contingency says how rare such a table is if the variant makes no difference:
p is 0.0046, about 0.005. The shuffling gave 0.003: both ways say the same. The small difference is because 2000 shuffles measure p roughly (6 cases), and chi2_contingency makes a small correction for a 2 × 2 table, which lifts p slightly.
The idea
How big is the lift?
p only says “hardly noise”. The manager asks something else: how much better is the new cart? For that you need an interval. As in the lesson on intervals, you draw new samples with replacement from each group and watch how the gap changes:
The lift is 0.62 points, and the 95% interval is from 0.21 to 1.04 points. Zero is outside the interval: that is the same “hardly noise”. But the interval also says how much: it may be a little over 0.2, it may be over 1 point.
More precisely, if you are curious
Here there are 2000 repeats, not 10,000, so that it runs fast: each one draws 14,000 rows. By the formula, the interval of a difference between two shares is the difference ± 1.96 × √(p₁(1 − p₁)/n₁ + p₂(1 − p₂)/n₂). It gives almost the same:
The idea
How many visits you need
How long should the test run? You decide that before you start, from two numbers: the conversion so far (3.1%) and the smallest lift that matters to you (0.6 points). There is a rule of thumb for an 80% chance to see the lift at the 0.05 threshold:
visits per group ≈ 16 × p × (1 − p) / lift²
You need 13,351 visits per group, 26,701 in total. The chance that the test catches a real lift is called power. You can check it with a simulation: make up 2000 tests with conversions of 3.1% and 3.7% and count how often p comes out under 0.05. rng.binomial(n, 0.031) gives how many orders come from n visits, each with a 3.1% chance:
With 2000 visits per group the test catches the lift in only 18% of cases, with 5000 in 37%. With 13,351 it is 78%, close to the 80% of the rule. A small test does not say “no difference”: it just cannot see.
More precisely, if you are curious
p_value is the check of two shares by formula (a z-test). It works on whole arrays at once, so 2000 tests take a moment. For a 2 × 2 table it gives almost the same as chi2_contingency. The 16 in the rule comes from the 0.05 threshold and 80% power: (1.96 + 0.84)² × 2 ≈ 16.
Try it
A bigger lift
The product team says: “We only care about a lift of at least 1 point.” How many visits per group does the rule need? Store them in per_group as a whole number.
Solution
base = 0.031
lift = 0.01
per_group = round(16 * base * (1 - base) / lift ** 2)
print(per_group)round(16 * 0.031 * (1 - 0.031) / 0.01 ** 2) gives 4806 per group. The lift is 0.01, a share, not 1. A bigger lift shows up with less data: 1 point needs about 2.8 times fewer visits than 0.6 points, because (1 / 0.6)² ≈ 2.8.
The idea
Do not peek before the end
The temptation is to look at the result every day. Here is p computed from all visits up to each day:
On day three p is 0.053, on day four 0.077, on day five 0.041. Had you stopped on day five, you would have declared a win. Here the new cart really is better, but that shows from the whole test. The rule “I stop as soon as p falls under 0.05” is the mistake itself.
Why? Check it with an A/A test: both groups see the same thing, and there is no difference. We simulate 1000 such tests of 14 days and look at p every day:
If you only look at the end, the false “wins” are 4.9%, as much as the 0.05 threshold promises. If you look every day and stop at the first p under 0.05, they are 21.7%: four times more. The noise has 14 chances to dip under the line.
The idea
Many metrics, many segments
The new cart brings more orders. Are they bigger, too? Compare the value per order:
€26.30 with the new cart and €26.71 with the old one, p = 0.73. There is no evidence that the cart changes the order value. That is not “we proved they are equal”: there may be a small difference, but this test cannot see it.
Now picture a report with 20 metrics: orders, value, returns, time on the page… Each metric without a real difference has a 5% chance to come out with p under 0.05. How often does at least one “win”?
One metric “wins” in 5.1% of cases, as expected. But at least one of 20 “wins” in 64.1% of tests. The same holds for segments: mobile, desktop, new customers, each day of the week. That is why the main metric is chosen before the test. A chance “win” in the 20th metric is an idea for the next test, not a conclusion.
The idea
Why the test says “causes”
Why do you believe the cart causes more orders, and does not just go along with them? Because the variant was assigned at random. Randomness evens out everything you do not measure. Here are the devices in both groups:
In both groups desktops are 35% and phones 65%. The same goes for the hour, the day, people’s habits: the groups differ only in the cart. So the gap in orders comes from it.
When the data are not from a test, this is not so. In the next lesson a banner seems to lift orders, yet on each device on its own it does not.
Exercise
A report for the manager
The manager asks: “Does the new cart bring more money per visit?” Store in per_visit the average revenue per visit for each variant (all visits, including those without an order), and in p the p-value of the comparison with scipy.stats.ttest_ind.
Solution
import pandas as pd
visits = pd.read_csv('ab_visits.csv')
from scipy import stats
per_visit = visits.groupby('variant')['revenue'].mean()
new = visits.loc[visits['variant'] == 'new', 'revenue']
old = visits.loc[visits['variant'] == 'old', 'revenue']
p = stats.ttest_ind(new, old).pvalue
print(per_visit.round(2))
print(round(p, 3))visits.groupby('variant')['revenue'].mean() gives €0.98 per visit with the new cart and €0.83 with the old one, and ttest_ind gives p ≈ 0.032. The visits without an order bring €0 and count in the mean. If you only take the orders, you get €26.30 and €26.71 and p = 0.73: a different question. The conclusion for the report: the new cart brings more money because it brings more orders, not bigger ones.
Had you planned it from the start: how many days should the test have run? The visits per day are len(visits) / 14 on average. Store in per_group the visits per group by the rule (a lift of 0.6 points at 3.1%), in per_day the visits per day, and in days the days, rounded up.
Solution
import math
import pandas as pd
visits = pd.read_csv('ab_visits.csv')
base = 0.031
lift = 0.006
per_group = 16 * base * (1 - base) / lift ** 2
per_day = len(visits) / 14
days = math.ceil(2 * per_group / per_day)
print(round(per_day), days)You need about 13,351 visits per group, and there are two groups: about 26,701 in total. About 2013 visits come in a day, so the days are math.ceil(26701 / 2013) = 14. The test was planned for exactly that long. With per_group / per_day you would get 7 days: enough for one group.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: The first chart · Sample and population · Confidence intervals · Testing a hypothesis
- Continues in: Correlation and cause
- Tasks on this topic: Statistics for decisions