Back to Portfolio
Case Study 3 / A/B Test

Why an A/B test can
fail silently

A self-directed case study applying formal experimental design, power analysis, and significance testing to a public e-commerce A/B test dataset, built to sharpen the statistical rigor that sits alongside my product analytics work.

Context: Self-directed, public dataset Scenario: E-commerce landing page test Users analyzed: 290,584 Tools: Python · SciPy · statsmodels
290,584
Users Analyzed
p=0.19
Not Statistically Significant
289,207
Sample Size Actually Required
50%
Of Required Power the Test Had

An old page versus a new page, and a question worth asking properly

The scenario: an e-commerce site tested a redesigned landing page against its existing one, with the question being whether the new page converts visitors at a higher rate than the old one. This is one of the most common tests any product or growth team runs, and it's also one of the easiest to get wrong in a way that looks fine on the surface.

I took this on as a self-directed exercise using a public, widely-used e-commerce A/B testing dataset, not a proprietary company result, specifically to close a gap I'd identified in my own skill set: I've A/B tested creative and content in past roles, but I hadn't formalized the statistical rigor, power analysis, sample size planning, and significance testing, that sits underneath a properly designed experiment. This case study is that rigor, applied end to end.

3,893 users were in the wrong bucket

Before any analysis, I checked the raw data for integrity. This dataset had a real, common problem: a subset of users assigned to the control group had somehow been shown the new page, and a subset assigned to treatment had been shown the old page, a classic randomization leak. Trusting group labels without checking them would have quietly corrupted every result downstream.

Data quality check: group / page mismatches
import pandas as pd df = pd.read_csv('ab_data.csv') mismatch = df[ ((df['group'] == 'control') & (df['landing_page'] == 'new_page')) | ((df['group'] == 'treatment') & (df['landing_page'] == 'old_page')) ] print(f"Mismatched rows: {len(mismatch):,}") # Mismatched rows: 3,893 df_clean = df[~df.index.isin(mismatch.index)] df_clean = df_clean.drop_duplicates(subset='user_id', keep='first') print(f"Clean rows: {len(df_clean):,}") # Clean rows: 290,584

After removing the mismatched rows and one duplicate user record, the clean dataset held at 290,584 users, split almost exactly evenly between control and treatment.

How many users would this test actually need?

This is the step that's easiest to skip and most costly to skip. Before trusting any result, I calculated the sample size this test would need to reliably detect a modest, business-relevant lift, a 2% relative improvement over the baseline conversion rate, at a standard 80% statistical power and 5% significance threshold.

Sample size calculation (power analysis)
import statsmodels.stats.api as sms baseline_rate = 0.1204 # control conversion rate mde_relative = 0.02 # minimum detectable effect: 2% relative lift target_rate = baseline_rate * (1 + mde_relative) effect_size = sms.proportion_effectsize(baseline_rate, target_rate) required_n = sms.NormalIndPower().solve_power( effect_size=effect_size, alpha=0.05, power=0.8, ratio=1.0, alternative='two-sided' ) print(f"Required sample size per group: {required_n:,.0f}") # Required sample size per group: 289,207

That's the number that should have been the target before the test launched. The actual dataset held roughly 145,300 users per group, about half of what would be needed to reliably detect an effect this small. That single number changes how every downstream result should be read.

Required vs. Actual Sample Size (Per Group)
Actual sample collected
Required for 80% power

The result: no significant difference, and that itself is informative

With the data validated and the power question on the table, I ran a two-proportion z-test comparing conversion rates between groups.

Two-proportion z-test
from statsmodels.stats.proportion import proportions_ztest count = [conv_treatment, conv_control] # [17264, 17489] nobs = [n_treatment, n_control] # [145310, 145274] z_stat, p_value = proportions_ztest(count, nobs, alternative='two-sided') print(f"z = {z_stat:.4f}, p = {p_value:.4f}") # z = -1.3109, p = 0.1899

Control converted at 12.04%, treatment at 11.88%, a small, statistically insignificant difference (p = 0.19, well above the 0.05 threshold). Taken alone, that reads as "the new page didn't help." Taken alongside the power analysis, the more accurate read is: this test wasn't designed to reliably detect an effect this small in the first place.

Conversion Rate by Group, with 95% Confidence Intervals
Control (old page)
Treatment (new page)

The overlapping confidence intervals are the visual version of the same story the p-value tells: there isn't enough separation between these two groups to call this a real, confident difference.

Statistical significance and practical significance are two different questions

A non-significant result doesn't mean "no effect." It means "this test couldn't distinguish a real effect from noise at this sample size." Those are different claims, and conflating them is one of the more common mistakes in how experiment results get reported up to stakeholders.

The honest recommendation here isn't "ship it" or "kill it." It's: this test needs roughly double the sample size to answer the question it was actually designed to answer. Extending the test, or accepting a larger minimum detectable effect if the business can't wait, are the two legitimate paths forward, not a coin flip on the current numbers.

What the data showed

Finding 01
3,893 users were mislabeled at the source before any analysis began. Validating group assignment against the actual page shown is a step that's easy to skip and expensive to skip wrong.
Finding 02
The test was underpowered by roughly half. 289,207 users per group were needed to reliably detect a 2% relative lift at 80% power; the actual test ran with about 145,300 per group.
Finding 03
The observed result (p = 0.19) is not evidence of "no effect." It's evidence that this test, as designed, couldn't reliably detect an effect of the size it was aimed at.
Finding 04
The right next step is a scoping conversation, not a launch decision. Extend the test to reach the required sample size, or explicitly agree to a larger minimum detectable effect the business is willing to act on with the data already in hand.

Why I built this

I've run A/B tests before, mostly on marketing creative and content, and made calls based on the results. What I hadn't formalized was the layer underneath that: calculating the sample size a test actually needs before trusting its outcome, and being precise about the difference between "not significant" and "no effect." This case study was a deliberate, self-directed way to close that gap using real methodology rather than just reading about it.

Note: this analysis uses a public, widely-referenced e-commerce A/B testing dataset (not a proprietary employer's data or results) and was built independently as a demonstration of experimental design and statistical analysis methodology.