数据驱动决策的黄金标准
A/B测试(随机对照实验)是科技行业做产品决策的基石。Google、Meta、字节跳动每天运行数千个A/B测试,用数据而非直觉来决定产品方向。
| 概念 | 含义 | 典型值 |
|---|---|---|
| α (第一类错误) | 误报:无效果却判有效 | 0.05 (5%) |
| β (第二类错误) | 漏报:有效果却判无效 | 0.2 (20%) |
| 1-β (统计功效) | 正确检出真实效果 | 0.8 (80%) |
| MDE (最小可检测效应) | 值得检测的最小提升 | 业务决定 |
| 基线转化率 | 对照组的历史转化率 | 业务数据 |
样本量太小→功效不足,可能漏掉真实效果;样本量太大→浪费流量,延迟决策。
import numpy as np
from scipy import stats
def sample_size_ab(alpha, power, p1, p2):
"""计算A/B测试每组所需样本量"""
z_alpha = stats.norm.ppf(1 - alpha/2)
z_beta = stats.norm.ppf(power)
p_avg = (p1 + p2) / 2
n = ((z_alpha * np.sqrt(2 * p_avg * (1 - p_avg)) +
z_beta * np.sqrt(p1*(1-p1) + p2*(1-p2))) / (p2 - p1)) ** 2
return int(np.ceil(n))
# 转化率5% → 7%
n = sample_size_ab(0.05, 0.8, 0.05, 0.07)
print(f"转化率5%→7%, 每组需要: {n} 人") # 2213
# 不同场景对比
print("\n样本量对照表:")
for p1, delta in [(0.05, 0.01), (0.05, 0.02), (0.10, 0.02), (0.10, 0.05)]:
p2 = p1 + delta
n = sample_size_ab(0.05, 0.8, p1, p2)
print(f" 基线{p1*100:.0f}% → {p2*100:.0f}% (+{delta*100:.0f}%): 每组{n}人")
import numpy as np
from scipy import stats
np.random.seed(42)
# 使用计算出的样本量
n = 2213
# 模拟数据
conversions_a = np.random.binomial(1, 0.05, n) # 对照组
conversions_b = np.random.binomial(1, 0.07, n) # 实验组
rate_a = conversions_a.mean()
rate_b = conversions_b.mean()
print(f"对照组转化率: {rate_a:.4f}")
print(f"实验组转化率: {rate_b:.4f}")
print(f"相对提升: {(rate_b-rate_a)/rate_a*100:.2f}%")
# Z检验(双比例检验)
pooled = (conversions_a.sum() + conversions_b.sum()) / (2 * n)
se = np.sqrt(pooled * (1 - pooled) * (2 / n))
z_score = (rate_b - rate_a) / se
p_value = 2 * (1 - stats.norm.cdf(abs(z_score)))
print(f"\nZ统计量: {z_score:.4f}")
print(f"p值: {p_value:.6f}")
print(f"显著性(α=0.05): {'✅ 显著' if p_value < 0.05 else '❌ 不显著'}")
# 置信区间
ci_a = stats.norm.interval(0.95, loc=rate_a, scale=np.sqrt(rate_a*(1-rate_a)/n))
ci_b = stats.norm.interval(0.95, loc=rate_b, scale=np.sqrt(rate_b*(1-rate_b)/n))
print(f"\n对照组95%CI: [{ci_a[0]:.4f}, {ci_a[1]:.4f}]")
print(f"实验组95%CI: [{ci_b[0]:.4f}, {ci_b[1]:.4f}]")
除转化率外,A/B测试也常用于比较连续指标(如人均收入、停留时长、点击次数):
# 连续指标A/B测试
np.random.seed(42)
n_continuous = 500
# 人均停留时长(分钟)
control = np.random.exponential(10, n_continuous) # 均值10分钟
treatment = np.random.exponential(11, n_continuous) # 均值11分钟
# t检验
t_stat, p_val = stats.ttest_ind(control, treatment)
print(f"对照组均值: {control.mean():.2f}分钟")
print(f"实验组均值: {treatment.mean():.2f}分钟")
print(f"t统计量: {t_stat:.4f}, p值: {p_val:.6f}")
# 效应量
d = (treatment.mean() - control.mean()) / np.sqrt(
(control.var() + treatment.var()) / 2)
print(f"Cohen's d: {d:.4f}")
| 陷阱 | 描述 | 解决方案 |
|---|---|---|
| 偷看(p-hacking) | 反复检查p值,显著就停 | 预先确定运行时间,用序贯检验 |
| 辛普森悖论 | 分组看有效,合并看无效 | 分层分析,检查子组一致性 |
| 新奇效应 | 新功能初期表现好,后回归 | 延长实验周期,看趋势 |
| 溢出效应 | A组行为影响B组 | 用geo-based或时间片实验 |
| 选择偏差 | 分组不随机 | 随机化验证(AA测试) |
| 多指标问题 | 看很多指标,总有一个"显著" | 多重比较校正,预注册主指标 |
# CUPED: 利用实验前数据降低方差
np.random.seed(42)
n = 1000
# 实验前数据(协变量)
pre_data = np.random.normal(100, 15, n)
# 实验期间数据 = 真实效果 + 噪声 + 与实验前相关
post_control = pre_data * 0.8 + np.random.normal(20, 10, n)
post_treatment = pre_data * 0.8 + np.random.normal(23, 10, n) # +3效果
# 原始t检验
t_orig, p_orig = stats.ttest_ind(post_control[:500], post_treatment[500:])
print(f"原始: t={t_orig:.3f}, p={p_orig:.4f}")
# CUPED调整
theta = np.cov(pre_data[:500], post_control[:500])[0,1] / np.var(pre_data[:500])
control_cuped = post_control[:500] - theta * (pre_data[:500] - pre_data[:500].mean())
treatment_cuped = post_treatment[500:] - theta * (pre_data[500:] - pre_data[500:].mean())
t_cuped, p_cuped = stats.ttest_ind(control_cuped, treatment_cuped)
print(f"CUPED: t={t_cuped:.3f}, p={p_cuped:.4f}")
print(f"方差缩减: {(1 - control_cuped.var()/post_control[:500].var())*100:.1f}%")