A/B Testing & Experiment Design
Shipping a model without A/B testing is like tightrope walking without a safety net — you have no idea whether it's actually better or just lucky.
1. What You'll Learn
- A/B testing fundamentals: hypothesis testing, p-value, confidence intervals, statistical power
- Experiment design: sample size calculation, experiment duration, traffic splitting strategies
- A/B testing ML models: comparing new vs. old models online and evaluating business metrics
- Multi-armed bandits (MAB): Thompson Sampling / UCB and the exploration-exploitation trade-off
- Alice's SaaS scenario: validating a conversion-rate lift for a new recommendation model with an A/B test
2. A Real Story from a Product Manager
(1) The Pain: Not Knowing Whether a New Model Is Actually Better
Alice's SaaS platform launched a new recommendation model, and the dashboard showed conversion rising from 3.2% to 3.5%. But the CEO pushed back: "This could just be natural fluctuation — how do you prove the model caused it?" Alice had no answer — without a controlled experiment, no improvement can be attributed.
(2) The A/B Testing Solution
A/B testing randomly splits users into Group A (old model) and Group B (new model), runs both at the same time, and uses statistics to determine whether the difference is significant.
from scipy import stats
# Group A: old model, Group B: new model
conversions_a, n_a = 320, 10000 # 3.2%
conversions_b, n_b = 350, 10000 # 3.5%
z_stat, p_value = stats.proportions_ztest(
[conversions_a, conversions_b], [n_a, n_b]
)
print(f"p-value: {p_value:.4f}")
print(f"Significant: {p_value < 0.05}")
(3) The Payoff: A 3-Week Experiment Proved a 2.3% Conversion Lift, Adding 500K USD per Year
Alice used a 3-week A/B test to scientifically validate the new model's improvement — p=0.03, statistically significant. With that confidence she rolled it out to everyone, adding roughly 500 thousand USD in annual revenue.
3. The Statistics Behind A/B Testing
(1) The Hypothesis Testing Framework
sequenceDiagram
participant Design as Design Experiment
participant Split as Random Split
participant Collect as Collect Data
participant Test as Statistical Test
participant Decision as Decision
Design->>Split: Define H0/H1, sample size
Split->>Collect: Group A (control) vs Group B (treatment)
Collect->>Test: After experiment period
Test->>Decision: p-value calculation
Decision->>Decision: p < α → Reject H0 (B is better)
Decision->>Decision: p ≥ α → Cannot reject H0
(2) Core Statistical Concepts
▶ Example: Hypothesis Testing Calculation
from scipy import stats
import numpy as np
# Two-proportion z-test
def ab_test_proportions(conversions_a, n_a, conversions_b, n_b, alpha=0.05):
p_a = conversions_a / n_a
p_b = conversions_b / n_b
p_pool = (conversions_a + conversions_b) / (n_a + n_b)
se = np.sqrt(p_pool * (1 - p_pool) * (1/n_a + 1/n_b))
z_stat = (p_b - p_a) / se
p_value = 1 - stats.norm.cdf(z_stat)
ci_low = (p_b - p_a) - 1.96 * se
ci_high = (p_b - p_a) + 1.96 * se
return {
"conversion_a": f"{p_a:.4f}",
"conversion_b": f"{p_b:.4f}",
"lift": f"{(p_b - p_a) / p_a * 100:.2f}%",
"z_stat": f"{z_stat:.3f}",
"p_value": f"{p_value:.4f}",
"ci_95": f"({ci_low:.4f}, {ci_high:.4f})",
"significant": p_value < alpha,
}
result = ab_test_proportions(320, 10000, 350, 10000)
for k, v in result.items():
print(f"{k:15s}: {v}")
Output:
# Functions defined successfully
| Concept | Meaning | Typical Threshold |
|---|---|---|
| H0 (null hypothesis) | A and B are no different | — |
| H1 (alternative hypothesis) | B is better than A | — |
| α (significance level) | Probability of a Type I error (false positive) | 0.05 |
| p-value | Probability of observing the current or a more extreme result under H0 | <0.05 |
| Power | Probability of correctly rejecting H0 (1-β) | 0.8 |
| Confidence interval | The plausible range of the true difference | 95% CI |
4. Experiment Design
(1) Sample Size Calculation
▶ Example: Calculating the Sample Size Needed for an A/B Test
from statsmodels.stats.power import zt_ind_solve_power
from statsmodels.stats.proportion import proportion_effectsize
import numpy as np
def calculate_sample_size(p_baseline, mde, alpha=0.05, power=0.8):
"""Calculate required sample size per group.
Args:
p_baseline: Baseline conversion rate
mde: Minimum Detectable Effect (relative lift)
alpha: Significance level
power: Statistical power
"""
p_new = p_baseline * (1 + mde)
effect_size = proportion_effectsize(p_new, p_baseline)
n = zt_ind_solve_power(effect_size=effect_size, alpha=alpha, power=power)
return int(np.ceil(n))
# Alice's SaaS: baseline 3.2%, wants to detect 10% relative lift
baseline = 0.032
for mde in [0.05, 0.10, 0.15, 0.20]:
n = calculate_sample_size(baseline, mde)
print(f"MDE={mde*100:.0f}%: Need {n:,} users per group "
f"(total {n*2:,}, ~{n*2/baseline/1000:.0f}k visitors)")
Output:
# Functions defined successfully
| MDE (Minimum Detectable Effect) | Sample Size per Group | Total Sample Size | Experiment Duration (50k DAU) |
|---|---|---|---|
| 5% relative lift | 152,000 | 304,000 | 6 days |
| 10% relative lift | 38,000 | 76,000 | 1.5 days |
| 15% relative lift | 17,000 | 34,000 | <1 day |
| 20% relative lift | 9,600 | 19,200 | <1 day |
(2) Traffic Splitting Strategies
| Strategy | Method | Pros | Cons |
|---|---|---|---|
| User-level | Hash by user_id | Consistent user experience | Requires login |
| Request-level | By request_id | Simple | The same user may see different results |
| Device-level | By device_id | Covers logged-out users | Multi-device users may conflict |
▶ Example: Consistent Hash Splitting
import hashlib
def assign_group(user_id, salt="experiment_1", num_groups=2):
"""Consistent hash assignment for A/B testing."""
hash_input = f"{salt}_{user_id}".encode()
hash_val = int(hashlib.md5(hash_input).hexdigest(), 16)
return hash_val % num_groups # 0=A, 1=B
# Verify equal split
user_ids = range(100000)
groups = [assign_group(uid) for uid in user_ids]
print(f"Group A: {groups.count(0)} ({groups.count(0)/len(groups)*100:.1f}%)")
print(f"Group B: {groups.count(1)} ({groups.count(1)/len(groups)*100:.1f}%)")
Output:
# Functions defined successfully
5. A/B Testing ML Models
(1) Comparing Models Online
▶ Example: Alice's Recommendation Model A/B Test
import numpy as np
from scipy import stats
rng = np.random.default_rng(42)
# Simulate 2-week A/B test for recommendation model
n_users = 20000 # Per group
# Group A: Old model (baseline conversion 3.2%)
conversions_a = rng.binomial(1, 0.032, n_users)
revenue_a = conversions_a * rng.exponential(200, n_users) # Avg 200 USD per conversion
# Group B: New model (true conversion 3.5%, 9.4% lift)
conversions_b = rng.binomial(1, 0.035, n_users)
revenue_b = conversions_b * rng.exponential(200, n_users)
# Analyze conversion rate difference
conv_rate_a = conversions_a.mean()
conv_rate_b = conversions_b.mean()
z_stat, p_value = stats.proportions_ztest(
[conversions_a.sum(), conversions_b.sum()],
[n_users, n_users]
)
# Analyze revenue difference
rev_per_user_a = revenue_a.mean()
rev_per_user_b = revenue_b.mean()
t_stat, t_pvalue = stats.ttest_ind(revenue_a, revenue_b)
print("=" * 50)
print("A/B TEST RESULTS: Recommendation Model")
print("=" * 50)
print(f"Conversion: A={conv_rate_a:.3%} vs B={conv_rate_b:.3%}")
print(f" Lift: {(conv_rate_b - conv_rate_a) / conv_rate_a * 100:.1f}%")
print(f" p-value: {p_value:.4f} {'✅ Significant' if p_value < 0.05 else '❌ Not significant'}")
print(f"\nRevenue/User: A={rev_per_user_a:.2f} vs B={rev_per_user_b:.2f} USD")
print(f" Lift: {(rev_per_user_b - rev_per_user_a) / rev_per_user_a * 100:.1f}%")
print(f" p-value: {t_pvalue:.4f}")
# Business impact
annual_lift = (rev_per_user_b - rev_per_user_a) * 50000 * 12 # 50k users * 12 months
print(f"\nProjected annual revenue lift: {annual_lift:,.0f} USD")
Output:
=
A/B TEST RESULTS: Recommendation Model
=
(2) Common A/B Testing Pitfalls
| Pitfall | Description | Solution |
|---|---|---|
| Peeking | Declaring significance before the deadline | Fix the experiment duration; only analyze at the end |
| Multiple testing | Testing many metrics / many groups | Bonferroni correction |
| Novelty effect | A new UI performs well at first | Extend the experiment duration |
| Contamination | Group A users see Group B's content | User-level splitting + log verification |
| Insufficient sample size | Statistical power not reached | Calculate the sample size in advance |
6. Multi-Armed Bandits (MAB)
(1) Balancing Exploration and Exploitation
Traditional A/B testing uses a fixed traffic split (50/50), while MAB adjusts dynamically — better-performing models get more traffic.
▶ Example: Thompson Sampling
import numpy as np
rng = np.random.default_rng(42)
# 3 model variants with true conversion rates
true_rates = [0.030, 0.035, 0.028] # Model B is best
n_arms = len(true_rates)
n_rounds = 10000
# Thompson Sampling
successes = np.zeros(n_arms)
trials = np.zeros(n_arms)
total_reward = 0
for t in range(n_rounds):
# Sample from Beta distribution for each arm
samples = [rng.beta(successes[i] + 1, trials[i] - successes[i] + 1) for i in range(n_arms)]
chosen = np.argmax(samples)
# Simulate user interaction
reward = rng.binomial(1, true_rates[chosen])
successes[chosen] += reward
trials[chosen] += 1
total_reward += reward
print(f"Thompson Sampling after {n_rounds} rounds:")
for i in range(n_arms):
print(f" Model {i}: chosen {int(trials[i]):5d} times, "
f"observed rate={successes[i]/max(trials[i],1):.4f} (true: {true_rates[i]:.4f})")
print(f"Total conversions: {int(total_reward)}")
Output:
# Executed successfully
| MAB Algorithm | Strategy | Exploration Method | Complexity |
|---|---|---|---|
| ε-Greedy | Explore randomly with probability ε | Fixed proportion | Low |
| UCB | Pick the arm with the highest upper confidence bound | Uncertainty-driven | Medium |
| Thompson Sampling | Sample from the posterior to pick an arm | Probability-driven | Medium |
| Dimension | A/B Testing | MAB |
|---|---|---|
| Traffic allocation | Fixed (50/50) | Dynamically adjusted |
| Exploration cost | High (50% goes to the worse one) | Low (adaptive) |
| Statistical rigor | High (hypothesis testing) | Low (asymptotic) |
| Use case | When statistical proof is needed | Fast iteration |
❓ FAQ
📖 Summary
- The core of A/B testing: random splitting + running simultaneously + statistical testing to scientifically attribute improvements
- Three statistical essentials: α (false positive rate) < 0.05, power ≥ 0.8, and a sufficient sample size
- Sample size depends on the baseline conversion rate and the MDE (minimum detectable effect) — detecting a smaller lift requires more samples
- Splitting strategies: user-level (recommended) > device-level > request-level; consistent hashing ensures stability
- A/B testing ML models: compare business metrics like conversion / revenue, not model metrics (AUC / R²)
- MAB allocates traffic dynamically, reducing exploration cost and fitting fast-iteration scenarios
📝 Exercises
- Basic (difficulty ⭐): Use
proportions_ztestto test whether two conversion rates (3.0% vs 3.5%, 10k users each) differ significantly, and compute the p-value and 95% confidence interval. Hint:stats.proportions_ztest. - Intermediate (difficulty ⭐⭐): Calculate the sample size for different MDEs and plot an "MDE vs sample size" curve. Hint:
zt_ind_solve_power+ matplotlib. - Challenge (difficulty ⭐⭐⭐): Build a Thompson Sampling simulator — 3 arms with different true conversion rates — and after 10,000 rounds compare Thompson Sampling with ε-Greedy on total reward and best-arm selection rate. Hint: Beta distribution sampling + comparison against random exploration.
← Previous: Model Deployment | Next: Production Monitoring & Model Drift →