Chapter 10 Project 8: A/B Testing Framework
A/B testing is the gold standard for causal inference in product development. But running tests incorrectly — stopping too early, using underpowered samples, or ignoring guardrail metrics — leads to bad decisions. In this chapter, we build a comprehensive A/B testing framework with sample size calculation, sequential testing, and Bayesian analysis.
10.1 Business Context
Product teams need to:
- Determine if a new feature improves conversion rates
- Calculate required sample sizes before launching tests
- Monitor experiments without inflating false positive rates
- Make ship/no-ship decisions with confidence intervals
10.2 Sample Size Calculation
Before running a test, calculate the required sample size to detect a meaningful effect.
library(pwr)
calculate_sample_size_r <- function(
baseline_rate,
mde, # Minimum detectable effect (relative)
alpha = 0.05,
power = 0.80,
ratio = 1.0 # Treatment:Control allocation ratio
) {
p1 <- baseline_rate
p2 <- baseline_rate * (1 + mde)
# Cohen's h effect size for proportions
h <- ES.h(p1, p2)
result <- pwr.2p.test(
h = h,
sig.level = alpha,
power = power
)
# Adjust for unequal allocation
n_per_group <- ceiling(result$n * (1 + ratio)^2 / (4 * ratio))
list(
n_per_variant = n_per_group,
total_n = n_per_group * 2,
effect_size = h,
baseline = p1,
expected_treatment = p2
)
}
# Example: Detect 15% relative lift from 12% baseline
calculate_sample_size_r(0.12, 0.15)Note: The pwr package uses Cohen’s h for proportion tests. For continuous outcomes, use pwr.t.test() with Cohen’s d.
10.3 Sequential Testing
Traditional fixed-horizon tests require waiting for the full sample. Group sequential testing allows early stopping while controlling the family-wise error rate.
library(gsDesign)
design_sequential_test <- function(
alpha = 0.05,
beta = 0.20,
k = 5 # Number of interim analyses
) {
gsDesign(
k = k,
test.type = 2, # Two-sided test
alpha = alpha,
beta = beta,
sfu = "OF", # O'Brien-Fleming spending function
sfl = "OF"
)
}The O’Brien-Fleming spending function is conservative early in the trial, requiring stronger evidence for early stopping. This protects against false positives from peeking.
10.4 Experiment Analysis
library(infer)
library(broom)
analyze_ab_test <- function(results_df) {
control <- results_df %>% filter(variant == "control")
treatment <- results_df %>% filter(variant == "treatment")
# Conversion rates
control_conv <- mean(control$converted)
treatment_conv <- mean(treatment$converted)
relative_lift <- (treatment_conv - control_conv) / control_conv
# Chi-square test for independence
contingency <- table(results_df$variant, results_df$converted)
chi_test <- chisq.test(contingency)
# Confidence interval for lift
se <- sqrt(
control_conv * (1 - control_conv) / nrow(control) +
treatment_conv * (1 - treatment_conv) / nrow(treatment)
)
ci_lower <- relative_lift - 1.96 * se / control_conv
ci_upper <- relative_lift + 1.96 * se / control_conv
# Revenue analysis (continuous outcome)
revenue_test <- t.test(control$revenue, treatment$revenue)
revenue_lift <- (mean(treatment$revenue) - mean(control$revenue)) /
mean(control$revenue)
# Guardrail metrics: ensure no harm to other KPIs
list(
control_conversion = scales::percent(control_conv, accuracy = 0.01),
treatment_conversion = scales::percent(treatment_conv, accuracy = 0.01),
relative_lift = scales::percent(relative_lift, accuracy = 0.01),
ci_95 = paste0("[", scales::percent(ci_lower, accuracy = 0.01), ", ",
scales::percent(ci_upper, accuracy = 0.01), "]"),
p_value = format(chi_test$p.value, digits = 4),
significant = chi_test$p.value < 0.05,
revenue_lift = scales::percent(revenue_lift, accuracy = 0.01),
revenue_p_value = format(revenue_test$p.value, digits = 4),
recommendation = ifelse(
chi_test$p.value < 0.05 && relative_lift > 0 && revenue_test$p.value > 0.05,
"SHIP",
"DO NOT SHIP"
)
)
}Important: The revenue_test$p.value > 0.05 guardrail ensures we don’t ship features that improve conversion but hurt revenue (e.g., by discounting too aggressively).
10.5 Bayesian A/B Testing
Bayesian methods provide intuitive probability statements: “There is a 94% probability that the treatment is better than control.”
library(bayesAB)
bayesian_ab_test <- function(control_conversions, control_trials,
treatment_conversions, treatment_trials,
n_samples = 1e5) {
AB1 <- bayesTest(
c(control_conversions, control_trials - control_conversions),
c(treatment_conversions, treatment_trials - treatment_conversions),
priors = c('alpha' = 1, 'beta' = 1), # Uniform prior
n_samp = n_samples,
distribution = 'bernoulliC'
)
list(
prob_treatment_better = AB1$posteriors$Probability$prob,
expected_lift = AB1$posteriors$Probability$lift,
credible_interval = quantile(
AB1$posteriors$Probability$posterior,
c(0.025, 0.975)
)
)
}10.6 Experiment Dashboard
# Shiny app for real-time experiment monitoring
experiment_ui <- dashboardPage(
dashboardHeader(title = "A/B Test Monitor"),
dashboardSidebar(
selectInput("experiment_id", "Experiment", choices = get_experiments()),
actionButton("refresh", "Refresh Data", icon = icon("sync"))
),
dashboardBody(
fluidRow(
valueBoxOutput("control_rate"),
valueBoxOutput("treatment_rate"),
valueBoxOutput("lift")
),
fluidRow(
box(plotlyOutput("conversion_trend"), width = 8),
box(tableOutput("metrics_table"), width = 4)
)
)
)10.7 Exercises
- Implement a multi-armed bandit algorithm (Thompson Sampling) for dynamic traffic allocation.
- Add stratification variables (device type, geo) to the analysis and check for interaction effects.
- Build a function that calculates the “probability of being best” for each variant in a multi-variant test.
- Implement a peeking-adjusted p-value using the
gsDesignpackage for a test with weekly looks.
In the next chapter, we’ll tie everything together with production deployment patterns, monitoring, and MLOps best practices.