A/B 测试实战:从实验设计到统计推断的工程落地
2026/6/15 12:56:12 网站建设 项目流程

A/B 测试实战:从实验设计到统计推断的工程落地

为什么 A/B 测试经常白做

A/B 测试是数据驱动决策的基础,但实际落地时,很多测试要么得不出结论,要么结论根本靠不住。常见问题包括:样本量不够导致统计功效低(有差异也测不出来)、多个实验同时跑导致互相干扰(辛普森悖论)、还没跑完就提前停止(偷窥问题导致假阳性)。

举个具体场景:产品团队想验证“新按钮颜色能否提升点击率”。实验跑了一周,新版本点击率从 3.2% 升到 3.5%,p 值 0.04——看着挺显著。但仔细一看:实验只覆盖了 2000 个用户,统计功效只有 35%。这意味着就算真实差异存在,也有 65% 的概率测不出来。这个“显著”结果大概率只是噪声。A/B 测试的关键,在于从设计到推断的每一步都得经得起推敲。

统计框架与实验设计

A/B 测试主要分三步:实验设计(定样本量和分流)、数据采集(确保测量无偏)、统计推断(控制两类错误)。任何一步出错,结论都不可信。

flowchart TB A[实验目标定义] --> B[效应量预估] B --> C[样本量计算] C --> D[分流策略设计] D --> D1[随机分流] D --> D2[分层分流] D --> D3[时间片轮转] D --> E[实验执行与监控] E --> E1[SRM 检验: 分流均衡性] E --> E2[指标监控: 中期检查] E --> E3[干扰检测: 实验污染] E --> F[统计推断] F --> F1[假设检验: p 值与置信区间] F --> F2[效应量估计: 点估计与区间估计] F --> F3[多重比较校正] F1 --> G[决策] F2 --> G F3 --> G

样本量计算

计算样本量主要看四个参数:基线转化率(p₁)、最小可检测效应(MDE)、显著性水平(α)、统计功效(1-β)。MDE 最关键——它决定了“我们想测出多小的差异”。MDE 设太小,样本量需求会爆炸;设太大,可能漏掉有价值的提升。

分流策略

简单随机分流在样本量有限时,容易出现组间分布不均(比如实验组里高端用户特别多)。分层分流(Stratified Randomization)先按关键维度(地区、用户等级)分层,再在每层内随机分流,能保证组间分布一致。

统计推断

第一类错误(假阳性):没差异却测出显著,由 α 控制。第二类错误(假阴性):有差异却没测出来,由 β 控制。A/B 测试必须同时控制这两类错误,不能光看 p 值。

代码实现

样本量计算

import math from dataclasses import dataclass @dataclass class SampleSizeResult: sample_per_group: int total_sample: int mde: float baseline_rate: float alpha: float power: float method: str class SampleSizeCalculator: def calculate_proportion(self, baseline_rate: float, mde: float, alpha: float = 0.05, power: float = 0.8) -> SampleSizeResult: """比例类指标的样本量计算(如转化率、点击率)""" if not (0 < baseline_rate < 1): raise ValueError(f"基线率必须在 (0,1) 之间,当前值: {baseline_rate}") if mde <= 0: raise ValueError(f"MDE 必须大于 0,当前值: {mde}") p1 = baseline_rate p2 = baseline_rate * (1 + mde) if p2 >= 1: raise ValueError(f"目标率 {p2:.4f} 超过 1,请减小 MDE") z_alpha = self._z_critical(1 - alpha / 2) z_beta = self._z_critical(power) p_bar = (p1 + p2) / 2 numerator = ( z_alpha * math.sqrt(2 * p_bar * (1 - p_bar)) + z_beta * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2)) ) ** 2 denominator = (p2 - p1) ** 2 n_per_group = math.ceil(numerator / denominator) return SampleSizeResult( sample_per_group=n_per_group, total_sample=n_per_group * 2, mde=mde, baseline_rate=baseline_rate, alpha=alpha, power=power, method="两比例 Z 检验", ) @staticmethod def _z_critical(cumulative_prob: float) -> float: from scipy.stats import norm return norm.ppf(cumulative_prob)

实验分流与 SRM 检验

import hashlib import numpy as np from scipy.stats import chi2_contingency class ExperimentSplitter: def simple_split(self, user_ids: list[str], control_ratio: float = 0.5) -> dict: groups = {"control": [], "treatment": []} for uid in user_ids: hash_val = int( hashlib.md5(uid.encode()).hexdigest(), 16 ) bucket = (hash_val % 10000) / 10000.0 if bucket < control_ratio: groups["control"].append(uid) else: groups["treatment"].append(uid) return groups def stratified_split(self, df, user_col: str, stratify_col: str, control_ratio: float = 0.5) -> dict: groups = {"control": [], "treatment": []} for stratum_value, stratum_df in df.groupby(stratify_col): user_ids = stratum_df[user_col].tolist() stratum_groups = self.simple_split(user_ids, control_ratio) groups["control"].extend(stratum_groups["control"]) groups["treatment"].extend(stratum_groups["treatment"]) return groups class SRMChecker: def check(self, control_count: int, treatment_count: int, expected_ratio: float = 0.5, alpha: float = 0.01) -> dict: total = control_count + treatment_count expected_control = total * expected_ratio expected_treatment = total * (1 - expected_ratio) chi2, p_value, dof, _ = chi2_contingency( np.array([ [control_count, treatment_count], [expected_control, expected_treatment] ]), correction=False, ) is_srm = p_value < alpha return { "is_srm_detected": is_srm, "p_value": round(p_value, 6), "chi2_statistic": round(chi2, 4), "control_count": control_count, "treatment_count": treatment_count, "observed_ratio": round(control_count / total, 4), "expected_ratio": expected_ratio, "recommendation": ( "分流正常,可继续分析" if not is_srm else "检测到分流不均衡,请排查分流逻辑或数据采集问题" ), }

统计推断与效应量估计

from scipy.stats import norm, ttest_ind import statsmodels.stats.proportion as proportion class ABTestAnalyzer: def analyze_proportion(self, control_success: int, control_total: int, treatment_success: int, treatment_total: int, alpha: float = 0.05) -> dict: p_control = control_success / control_total p_treatment = treatment_success / treatment_total z_stat, p_value = proportion.proportions_ztest( count=[treatment_success, control_success], nobs=[treatment_total, control_total], alternative="two-sided", ) ci_low, ci_high = proportion.confint_proportions_2indep( count1=treatment_success, nobs1=treatment_total, count2=control_success, nobs2=control_total, alpha=alpha, method="wald", ) relative_lift = (p_treatment - p_control) / p_control return { "is_significant": p_value < alpha, "p_value": round(p_value, 6), "z_statistic": round(z_stat, 4), "control_rate": round(p_control, 6), "treatment_rate": round(p_treatment, 6), "absolute_diff": round(p_treatment - p_control, 6), "relative_lift": round(relative_lift, 4), "ci_95": (round(ci_low, 6), round(ci_high, 6)), "conclusion": self._make_conclusion( p_value, alpha, relative_lift ), } def _make_conclusion(self, p_value: float, alpha: float, lift: float) -> str: if p_value >= alpha: return ( f"未检测到显著差异(p={p_value:.4f})," f"不能拒绝原假设。建议检查统计功效是否充足。" ) direction = "正向" if lift > 0 else "负向" return ( f"检测到{direction}显著差异(p={p_value:.4f})," f"相对提升 {lift:.2%}。" )

权衡与边界

维度固定时长测试序贯测试(Sequential)
样本量需求固定,提前计算可提前停止,平均节省 20%–30% 样本
假阳性控制严格,α 即为实际水平需要校正,否则假阳性膨胀
实施复杂度高,需持续监控边界
适用场景样本量充足,时间不紧迫样本量有限,需快速决策

权衡一:MDE 的设定。MDE 设太小,样本量可能超出流量承载能力;设太大,可能漏掉有业务价值的小幅提升。建议把 MDE 定为“最小业务意义差异”——即对业务决策有实际影响的最小变化。

权衡二:多重比较的校正。同时测多个指标时,假阳性率会随指标数量膨胀。5 个指标在 α=0.05 下的整体假阳性率约为 23%。建议用 Bonferroni 或 BH 校正控制族错误率。

权衡三:实验时长与周期效应。时长过短可能覆盖不了完整的用户行为周期(比如周末和工作日差异)。建议实验时长至少覆盖一个完整周期,通常 1–2 周。

落地建议

A/B 测试的核心是“先设计再执行,先检验再推断”。样本量计算保障统计功效,分流策略保障组间可比性,SRM 检验保障数据质量,统计推断控制两类错误——每一步都是结论可信的前提。

具体落地可以分三步:

  1. 建立样本量计算模板,确保每个实验启动前都有明确的功效保障。
  2. 实现分流与 SRM 检验自动化,实验启动后立即验证分流均衡性。
  3. 构建统计推断工具包,统一团队的分析方法和结论标准。

一个结论不可靠的 A/B 测试,比不做测试更危险。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询