Skip to content

Grokem · https://www.grokem.com/data/data-science/experiment-design/ab-test-analysis-python

Analyse an A/B test in Python: rates, z-test and confidence interval

Compare two conversion rates the careful way: the difference, a p-value, a confidence interval, and how many visitors you needed in the first place.

Grokem Editorial

Editorial desk

Published
  • Intermediate
  • 25 min to complete
Tested withPython3.11

Before you start

  • Python 3.8 or newer (statistics.NormalDist)
  • The idea of a conversion rate: conversions divided by visitors
  • No external packages are needed

An A/B test shows version A to some visitors and version B to others, then asks whether B converts better. The danger is mistaking luck for improvement. This tutorial analyses a result properly using only Python's standard library.

The result to analyse

A page change was tested on 8,000 visitors, split evenly:

  • Control: 4,000 visitors, 480 conversions
  • Variant: 4,000 visitors, 540 conversions

That is 12.0% against 13.5%. Is the variant really better?

Step 1: Compute the difference and test it

The standard tool for comparing two proportions is a two-proportion z-test. It asks: if both groups truly had the same underlying rate, how surprising would a gap this large be?

python
from math import sqrt
from statistics import NormalDist

normal = NormalDist()


def analyse(control_users, control_conversions, variant_users, variant_conversions, confidence=0.95):
    p1 = control_conversions / control_users
    p2 = variant_conversions / variant_users

    # test: is the difference bigger than chance would explain?
    pooled = (control_conversions + variant_conversions) / (control_users + variant_users)
    se_test = sqrt(pooled * (1 - pooled) * (1 / control_users + 1 / variant_users))
    z = (p2 - p1) / se_test
    p_value = 2 * (1 - normal.cdf(abs(z)))

    # estimate: how big is the difference, give or take?
    se_diff = sqrt(p1 * (1 - p1) / control_users + p2 * (1 - p2) / variant_users)
    margin = normal.inv_cdf(1 - (1 - confidence) / 2) * se_diff
    return {
        "control_rate": p1, "variant_rate": p2, "difference": p2 - p1,
        "relative_lift": (p2 - p1) / p1, "z": z, "p_value": p_value,
        "ci_low": p2 - p1 - margin, "ci_high": p2 - p1 + margin,
    }

Two standard errors appear, and they are different on purpose. The test assumes no difference (the null hypothesis), so it pools both groups into one rate. The interval estimates the actual difference, so it uses each group's own rate.

Step 2: Run it and read the result

python
result = analyse(4000, 480, 4000, 540)
print(f"control  {result['control_rate']:.1%}")
print(f"variant  {result['variant_rate']:.1%}")
print(f"difference {result['difference']:+.2%} points  (relative {result['relative_lift']:+.1%})")
print(f"z = {result['z']:.2f}, p = {result['p_value']:.3f}")
print(f"95% interval for the difference: {result['ci_low']:+.2%} to {result['ci_high']:+.2%}")
text
control  12.0%
variant  13.5%
difference +1.50% points  (relative +12.5%)
z = 2.01, p = 0.044
95% interval for the difference: +0.04% to +2.96%

How to read it:

  • The lift is 1.5 percentage points, or 12.5% relative to the control.
  • p = 0.044 means that if there were truly no difference, a gap this big or bigger would turn up about 4.4% of the time. Below the usual 5% line, so the result is "statistically significant".
  • The interval is the more useful number. It runs from +0.04 to +2.96 points. The data are compatible with a real improvement that is almost nothing, or one that is nearly three points. "Significant" here means "probably better", not "better by 1.5 points".

Step 3: Ask whether the test was big enough

A test should be sized before it runs. This function returns the visitors needed in each group to detect a given absolute lift with the usual 5% significance and 80% power (an 80% chance of noticing a real effect of that size).

python
def sample_size_per_group(baseline, minimum_effect, alpha=0.05, power=0.8):
    """Visitors needed in each group to detect an absolute lift of `minimum_effect`."""
    p1, p2 = baseline, baseline + minimum_effect
    z_alpha = normal.inv_cdf(1 - alpha / 2)
    z_beta = normal.inv_cdf(power)
    variance = p1 * (1 - p1) + p2 * (1 - p2)
    return int(((z_alpha + z_beta) ** 2 * variance) / (minimum_effect ** 2)) + 1


print("visitors per group to detect +1.5 points from a 12% baseline:", sample_size_per_group(0.12, 0.015))
print("visitors per group to detect +0.5 points from a 12% baseline:", sample_size_per_group(0.12, 0.005))
text
visitors per group to detect +1.5 points from a 12% baseline: 7758
visitors per group to detect +0.5 points from a 12% baseline: 67493

Our test used 4,000 per group, about half of what a +1.5 point effect needs. That is why the interval is so wide and why the result sits only just under the 5% line. Two practical lessons follow:

  1. Smaller effects need far more visitors. Detecting a third of the lift costs roughly nine times the traffic.
  2. Decide the smallest improvement worth having, then compute the sample. If you cannot afford the visitors, you cannot answer the question, and it is better to know that before you start.

Common mistakes

  • Peeking and stopping early. Checking daily and stopping when p dips below 0.05 inflates false positives badly. Fix the sample size in advance, or use a method built for sequential looks.
  • Running many variants or metrics and reporting the winner. Each extra comparison is another chance of a fluke. Decide the main metric first.
  • Unequal or broken splits. If the groups are not about the same size, or the assignment is not random, the test is invalid. Check the split before looking at results.
  • Reading "not significant" as "no effect". It means the test could not tell. The interval shows what remains possible.

Check your work

Recompute the two rates by hand from the counts (480 ÷ 4,000 and 540 ÷ 4,000). The difference must equal the lift printed, and the interval must be centred on it. If you swap the groups, the sign of the z statistic flips and the p-value stays the same.

Expected result

A script prints both conversion rates, the lift, a z statistic and p-value, a 95% confidence interval for the difference, and the sample size a test like this needs.

Troubleshooting

ProblemLikely causeFix
The p-value is tiny but the lift is trivialWith a very large sample, even a difference too small to matter is statistically significant.Look at the confidence interval and ask whether the smallest plausible gain is worth the change.
The result flips when you re-run the testThe test was too small to detect the effect reliably, or you stopped it early the moment it looked good.Fix the sample size in advance and do not stop on the first significant reading.

Frequently asked questions

Why not just compare the two percentages?

Because of chance. Two groups of equal quality will show different rates by luck alone, and the statistics tell you whether the gap is bigger than luck would usually produce.

Can I use this for metrics that are not yes/no?

No. This is for proportions (converted or not). Averages such as revenue per visitor need a different test, and skewed money data often needs more care.

Next steps

  • Decide the sample size before you start your next test
  • Read the p-values guide to avoid the common misreadings

References

From Grokem: https://www.grokem.com/data/data-science/experiment-design/ab-test-analysis-python

How did this strike you?

Be the first to react – it is anonymous.

Community

Questions about this?

Ask in Questions and help and get help from other readers.

Ask a questionBrowse Questions and help

Comments (2)

  • Lucía

    How many visitors do I need before I look at the result?

    • Grokem Editorial

      It depends on your baseline rate and on the smallest change you care about, and you should fix the number before the test starts. That calculation is called a power analysis; this article analyses a finished test and does not cover it. Looking early and stopping when the result looks good inflates false positives.

Choose what we may use. You can change this at any time with “Privacy choices” at the foot of every page. Details are in the Cookie policy and the Privacy policy.

Strictly necessary

Remembers your theme, the lessons you marked as done and this choice. It stays on your device, tracks nothing and cannot be switched off.

Always on
What exactly is collected, and why

Grokem’s own counts

Collects
The pages you open and how long you stay, how far you scroll, where you came from, your device type, browser, screen size, language and time zone. Your IP address is turned into a code that changes every day; it is never stored as it is.
Why
To see how long lessons are read and where readers leave. Not shared with anyone.
Stored
A random visitor ID, a visit ID and the time of your last page view in your browser’s storage (gr_vid, gr_sid, gr_last). No name or email is attached.