🏠 总目录📚 本教程 12 · A/B 你以为你懂了 ← →
📑 本页目录(点开跳转)

12 · A/B 实验:你以为你懂了

⏱ 48 分钟 | ⭐ 每一条都有人踩过


🎯 一句话

A/B 实验的原理五分钟能讲完,但把它做对需要避开十几个坑 —— 而这些坑不会报错,只会给你一个看起来很合理的错误结论。


🎲 一、先把原理说清(五分钟)

因果链

随机把用户分成两组→一组用旧的(对照),一组用新的(实验)
因为是【随机】分的,两组在所有方面都可比
观察到的差异只可能来自「你改的那个东西」 ⭐

🔑 随机化的全部意义:它让两组在你知道的和你不知道的因素上都均衡。 「你不知道的」那部分,是任何观察性方法都做不到的 —— 这就是为什么 A/B 是因果推断的黄金标准(第 13 章会讲没它时怎么办)。


📏 二、坑一:样本量不够就下结论

先算需要多少样本,再开始实验。

$$n \approx \frac{16\,\sigma^2}{\delta^2}\quad(\text{每组,}\alpha=0.05,\ \text{power}=0.8)$$

💡 人话:要检测的差异(δ)越小,需要的样本量按平方增长。

信息关系

⭐ 一个必须建立的直觉:
要检测的提升【减半】→样本量【×4】
转化率 5%,想检测 +0.5%(相对 +10%)
每组约需 3 万人
想检测 +0.25%(相对 +5%)
每组约需 12 万人 ⭐
from statsmodels.stats.power import zt_ind_solve_power
from statsmodels.stats.proportion import proportion_effectsize

def sample_size(p0, lift_abs, alpha=0.05, power=0.8):
    """p0: 基线转化率;lift_abs: 想检测的绝对提升"""
    es = proportion_effectsize(p0 + lift_abs, p0)
    return int(zt_ind_solve_power(effect_size=es, alpha=alpha, power=power))

print(sample_size(0.05, 0.005))   # 想检测 5% → 5.5%

但实践中更该跑的是反方向:我只有这么多流量,到底能检测出多大的提升?

from statsmodels.stats.power import zt_ind_solve_power
from statsmodels.stats.proportion import proportion_effectsize


def mde(n_per_group, p0, alpha=0.05, power=0.8):
    """MDE = 最小可检测提升。⭐ 实验设计时比 sample_size 更常用"""
    es = zt_ind_solve_power(nobs1=n_per_group, alpha=alpha, power=power)
    lo, hi = 0.0, 1.0 - p0                       # 提升越大,效应量越大 → 单调,可二分
    for _ in range(60):
        mid = (lo + hi) / 2
        if proportion_effectsize(p0 + mid, p0) < es:
            lo = mid
        else:
            hi = mid
    return (lo + hi) / 2

print(mde(30000, 0.05))     # 每组 3 万人、基线 5% → ≈ 0.005(即 5% → 5.5%)
print(mde(5000,  0.05))     # 每组 5 千人        → ≈ 0.0123(要涨 25% 才测得出)💀

⭐ 这个函数应该在实验开始前跑一次,而不是结束后: 如果算出来的 MDE 比「业务上有意义的提升」还大,这个实验就不该跑 —— 跑了也只会得到一个"不显著",然后你还得花两周才知道自己白跑了。 上面第二行就是典型:每组只有 5 千人时,除非提升 25%,否则什么都测不出来。

流量不够怎么办:CUPED(方差缩减) ⭐

因果链

核心观察:用户之间本来就差别巨大
(重度用户天天点,轻度用户一周点一次)
指标的方差里,绝大部分是【个体差异】,不是【实验效果】
⭐ 如果你有这批用户【实验开始前】的同口径指标,
就能把这部分个体差异扣掉 —— 均值不变,方差变小
import numpy as np

def cuped(y, y_pre):
    """y: 实验期指标;y_pre: 同一批用户实验【开始前】同口径的指标 ⭐"""
    theta = np.cov(y, y_pre)[0, 1] / np.var(y_pre)    # ⭐ 方差最小化的最优系数
    y_adj = y - theta * (y_pre - y_pre.mean())        # ⭐ 均值不变,所以估计仍无偏
    return y_adj, 1 - np.var(y_adj) / np.var(y)       # 第二个返回值 = 方差降低比例
实验前后指标相关系数 ρ 方差降低 等价效果
0.3 9% 聊胜于无
0.6 36% ⭐ 两周的实验可以缩到 9 天
0.8 64% ⭐ 同样时长下 MDE 降到原来的 0.6 倍

💡 理论上方差正好降低 ρ²(ρ 是实验期与实验前指标的相关系数)。 对留存、时长、GMV 这类"个体稳定性高"的指标,ρ 常常在 0.6–0.8 —— 这是一个改十行代码就能把实验效率翻倍的方法,却常年被忽略。

⚠️ 一条铁律:y_pre 必须取自实验开始之前。 用实验期内的数据当协变量 = 把实验效果本身扣掉了, 这就是第 4 章的特征穿越在实验分析里的形态。

⚠️ 样本量不够时会发生什么: 你会得到一个不显著的结果,然后误以为「新模型没用」—— 但实际可能是你的实验根本没有能力检测出这个大小的提升。 这叫 power 不足,它造成的损失是"把好东西扔掉",而且你永远不会知道。


👀 三、坑二:偷看(peeking)

对照

❌ 错误做法:

每天看一眼,一旦 p < 0.05 就宣布成功、停止实验

为什么错:

p 值本身是波动的。

即使两组【完全没有差异】,

连续看 10 次,也有很大概率某一次会 p < 0.05 💀

💡 一个能说服人的数字:

流程图

两组真的没差异(A/A 实验)
· 只在实验结束时看 1 次→假阳性率 5% ✅
· 每天看一次,看 10 天→假阳性率约 30% 💀
· 一直盯着看→假阳性率趋近 100% 💀💀

三个正确做法:

做法 说明
提前定好样本量和时长,到点才看 ⭐ 最简单、最可靠
用序贯检验(如 mSPRT、always-valid p-value) 允许随时看,但方法本身要换
只把中途的观察用于止损,不用于宣布成功 ⭐ 实用折中:跌得厉害可以停,涨了不能提前宣布

⭐ 第三条是实践中最常用的: 「负向可以早停,正向必须等满」 —— 因为早停止损的代价是可接受的, 而提前宣布成功会系统性地高估效果。


⏱️ 四、坑三:实验时长不够一个周期

结果对照

⭐ 最少跑满一个完整的【周】—— 而不是"样本量够了就停"
原因:
· 工作日和周末的用户行为完全不同
· 新奇效应(novelty effect):新东西刚上线时用户会多点几下 ⭐
前 2-3 天的数据系统性偏高
· 有些行为周期长(下单、续费)

⚠️ 新奇效应是最容易骗人的: 前三天涨 8%,一周后变成 +2%,一个月后变成 0。 只跑三天的实验,会让你把「新鲜感」当成「效果」。

对策:看逐日的效果曲线,而不只是累计值 —— 如果效果在稳定衰减,那多半是新奇效应。


🧪 五、坑四:多重比较

关键信息

同时看 20 个指标,每个都用 p<0.05
即使新模型完全没用,
平均也会有 1 个指标"显著" 💀
同理:同时跑 20 个实验组也一样

三个应对:

操作步骤

  1. 提前指定【一个】主指标(primary metric)⭐
  2. 其他都是次要指标,只用来看有没有副作用
  3. 多重比较校正(Bonferroni:阈值除以比较次数)
  4. 简单但保守
  5. 分层:主指标决定成败,护栏指标只看有没有明显恶化

⭐ 第 ① 条是最重要的实验纪律: 实验开始前就写下「我用哪个指标判断成败」。 否则事后从 20 个指标里挑一个涨了的,在统计上毫无意义 (这叫 HARKing —— 先看结果再编假设)。


🕸️ 六、坑五:分流单元错了 / 有干扰

分流单元

结果对照

❌ 按【请求】分流→同一用户一会儿新一会儿旧
✅ 按【用户】分流→同一用户始终在同一组 ⭐
⚠️ 但如果你的效应会在用户之间传播,按用户分也不够

网络效应(SUTVA 违背)

结果对照

A/B 的基本假设:一个用户的处理不影响另一个用户的结果
(统计上叫 SUTVA)
什么时候会破:
· 社交产品:实验组用户发了内容,对照组用户也看到了 💀
· 双边市场:实验组司机被多派单→对照组司机接不到单 💀
· 共享库存/预算:实验组多花了预算→对照组预算少了 💀

对策:换分流单元 —— 按城市、按时间片、按社交圈分流(cluster randomization), 代价是样本量大幅下降(有效样本变成"簇"的个数,不是用户数)。


📊 七、坑六:只看均值

对照

总体 +2%,但可能是:

· 90% 的用户没变化,10% 的重度用户 +20%

· 或者:新用户 −5%,老用户 +4% ⭐

⭐ 一定要看【分布】和【分人群】:

· 分位数(P50 / P90)而不只是均值

· 按新老、渠道、活跃度分层看

🔗 分人群看还能避免第 14 章那种反转。


📋 八、实验前的检查清单

逐项核对

设计

  • 主指标只有一个,且实验前就写下来 ⭐

  • 算过样本量,知道能检测多大的提升

  • 时长 ≥ 一个完整周期(通常 ≥ 1 周)

  • 分流单元正确,且哈希加了实验名的盐(第 3 章)

  • 想过有没有网络效应

    运行

  • 先跑 A/A 验证分流系统本身没问题 ⭐

  • 中途只用于止损,不提前宣布成功

  • 护栏指标(延迟、错误率、核心业务)在看

    分析

  • 报告置信区间,不只报 p 值和均值 ⭐

  • 分人群看过

  • 检查了两组的样本量是否符合预期(SRM 检查)⭐

⭐ 最后一条 SRM(Sample Ratio Mismatch)值得单独说: 如果你设的是 50/50,实际却是 50.4/49.6 —— 在大样本下这个偏差在统计上是不可能的,说明分流系统有 bug。 SRM 一旦出现,整个实验结果都不可信,必须先修分流。 这是业界公认最有效的实验健康检查,一行代码就能做:

from scipy.stats import chisquare

def srm_check(counts, expect=None, alpha=1e-3):
    """counts: 各组实际样本量,如 [n_a, n_b];expect: 期望比例,默认等分
    ⭐ 阈值用 1e-3 而不是 0.05 —— 这个检查每天跑很多次,要防多重比较"""
    counts = list(counts)
    k, total = len(counts), sum(counts)
    expect = expect or [1 / k] * k
    p = chisquare(counts, [total * e for e in expect]).pvalue
    return p, ("🔴 SRM!分流有问题,结果不可信" if p < alpha else "✅ 正常")

print(srm_check([1043221, 1051884]))     # 差 0.83% → p ≈ 3e-9,铁定有问题 💀
print(srm_check([50120, 49880]))         # 差 0.24% → p ≈ 0.23,正常波动 ✅

⭐ 更该做的是「分层 SRM」:总体比例正常,不代表每一层都正常。 按平台、App 版本、地域、新老用户各跑一次 —— 哪一层 SRM 了,根因几乎就写在那一层的名字上。

💀 一个真实案例:一次 +6.2% 的胜利,三个月后发现是假的

对照

实验:新排序模型 vs 旧模型,主指标 CTR

结果:+6.2%,p = 0.003,跑满 14 天,全量上线 🎉

三个月后:大盘 CTR 纹丝不动

复盘时唯一被跳过的检查:

项 数字
实验组样本量 1,043,221
对照组样本量 1,051,884
差异 0.83%
SRM 卡方 p 值 ≈ 3×10⁻⁹ 💀

根因链条:

关键信息

新模型的响应里多了一个字段
某个老版本 App(占总流量 4.1%)解析失败、直接崩溃
这批用户【没有产生后续曝光日志】
他们从实验组的分母里「消失」了 💀
⭐ 而消失的恰好是【老版本用户 = 低活跃用户 = CTR 本来就低的那批】
实验组剩下的人天生 CTR 更高
+6.2% 全部来自「实验组少了一批 CTR 低的用户」

💀 代价不止是这一个实验: 后续 5 个实验都以这个"新基线"为对照,三个月的迭代方向被整体带偏; 而且崩溃的那 4.1% 用户里,有相当一部分直接卸载了。

⭐ 而发现它只需要一行 srm_check,耗时 10 秒。 这就是为什么 SRM 被公认为投入产出比最高的一项实验检查。

SRM 出现时的排查顺序(照抄即可):

结果对照

① 分层看:哪个平台/版本/地域 SRM?→根因通常就在那一层 ⭐
② 是不是有一组用户【根本没上报日志】(崩溃、白屏、超时)⭐
③ 分流哈希是不是没加实验名的盐(和上一个实验相关了,第 3 章)
④ 有没有中途改过分流比例 —— 改比例前后的数据不能合并算
⑤ 过滤条件是不是对两组不对称(例如"只看有曝光的用户")💀
⑥ 机器人/爬虫流量是不是只被其中一组吸引
⚠️ 在修好之前,不要看任何实验结论 —— 包括"看起来很合理"的那些

⚠️ 九、一张坑表(每一条都有人踩过)

坑 后果 怎么修
不算样本量就开跑 两周后得到"不显著",白跑 开跑前算 MDE,比业务有意义的提升还大就别跑 ⭐
偷看,一显著就停 ⭐ 假阳性率从 5% 涨到 30% 定好时长;中途只用于止损
只跑 3 天 新奇效应被当成效果 至少一个完整周 + 看逐日曲线
事后从 20 个指标里挑赢的 结论在统计上毫无意义(HARKing) 实验前写死唯一主指标 ⭐
不查 SRM 💀 整个结论是假的,还会污染后续实验 一行卡方,分层各跑一次
只看有曝光的用户 过滤本身对两组不对称 → 人为造出 SRM 分母固定为进组的全部用户 ⭐
中途改分流比例后合并算 两段数据结构不同,均值被污染 改比例即视为新实验,重新计时
用实验期数据做 CUPED 协变量 把实验效果本身扣掉了 y_pre 只能取实验开始前 ⭐
多个实验共用同一个哈希 实验之间相关,效应互相污染 哈希加实验名的盐(第 3 章)
只报 p 值不报置信区间 读者无法判断"这个提升值不值得做" 永远报 点估计 + 置信区间 + 绝对值 ⭐

🔗 十、和站内其他章的关系

相关的地方 和这一章的关系
推荐算法 12 A/B 基础 这里是它的深水区
第 3 章哈希加盐 分流单元
Kaggle 22 shake-up 同样是"小样本上的结论不稳"
数学原理 09 样本量和精度的平方关系同源
数学原理 08方差 CUPED 缩的就是它
智能体工程 14 Evals Agent 场景的同一套实验纪律

✅ 检查点

  1. 随机化的全部意义是什么?为什么它是黄金标准?
  2. 要检测的提升减半,样本量要变几倍?power 不足会造成什么损失?
  3. 为什么说实验前该算的是 MDE 而不是样本量?每组 5 千人、基线 5% 时 MDE 大约多少?
  4. CUPED 在做什么?方差降低和相关系数是什么关系?ρ=0.6 时相当于省了多少时间?CUPED 有哪条铁律?
  5. 什么是 peeking?每天看一次看 10 天,假阳性率大约多少?实践中最常用的折中做法是什么?
  6. 新奇效应怎么骗人?怎么识别?
  7. 多重比较问题怎么解?最重要的实验纪律是什么?
  8. 什么是 SUTVA 违背?举两个场景。对策的代价是什么?
  9. 什么是 SRM?为什么它一出现整个实验就不可信?用那个 +6.2% 的案例说明它的根因链条。
👀 答案
  1. 它让两组在你知道的和你不知道的因素上都均衡。"你不知道的"那部分是任何观察性方法都做不到的——所以它是因果推断的黄金标准。
  2. ×4(样本量按 δ 的平方反比增长)。power 不足会让你得到不显著的结果,误以为"新模型没用"而把好东西扔掉,且你永远不会知道。
  3. 因为实验里流量通常是给定的,真正的问题是"这些流量够不够"。MDE 比业务上有意义的提升还大 → 这个实验根本不该跑,跑了也只会拿到一个"不显著",而且要两周后才知道白跑了。每组 5 千人、基线 5% 时 MDE ≈ 0.0123,即要涨 25% 才测得出来。
  4. 用这批用户实验开始前的同口径指标做协变量,把个体差异从方差里扣掉;均值不变所以估计仍无偏,方差变小。方差正好降低 ρ²。ρ=0.6 → 方差降 36% → 两周的实验可以缩到 9 天。铁律:y_pre 必须取自实验开始之前,用实验期内的数据当协变量等于把实验效果本身扣掉了(实验分析版的特征穿越)。
  5. 每天看一眼、一旦 p 跌破 0.05 就宣布成功。看 10 天假阳性率约 30%(只看一次是 5%,一直盯着趋近 100%)。折中做法是「负向可以早停,正向必须等满」——中途观察只用于止损,不用于宣布成功。
  6. 新东西刚上线用户会多点几下,前 2-3 天数据系统性偏高;前三天 +8%,一周后 +2%,一个月后 0。识别:看逐日效果曲线而不只是累计值,效果稳定衰减就多半是它。
  7. ①提前指定一个主指标 ②多重比较校正 ③分层(主指标定成败、护栏指标看恶化)。最重要的纪律是实验前就写下用哪个指标判断成败,否则事后挑一个涨的(HARKing)在统计上毫无意义。
  8. 「一个用户的处理不影响另一个用户的结果」被违反。场景:社交产品(实验组发的内容对照组也看到)、双边市场(实验组司机多派单→对照组接不到)、共享预算。对策是换分流单元(按城市/时间片/社交圈),代价是有效样本变成"簇"的个数,样本量大幅下降。
  9. 设 50/50 实际却是 50.4/49.6 —— 大样本下这个偏差统计上不可能,说明分流系统有 bug。分流都错了,两组就不再可比,所有结论都失去基础。 案例:实验组 1,043,221 / 对照组 1,051,884,差 0.83%,卡方 p ≈ 3×10⁻⁹。链条是:新模型响应多了一个字段 → 某老版本 App(占 4.1%)解析失败崩溃 → 这批用户没产生曝光日志,从实验组分母里消失 → 而消失的恰好是低活跃、CTR 本来就低的人 → +6.2% 全部来自"实验组少了一批 CTR 低的用户"。代价是后续 5 个实验都建立在错误基线上,三个月方向被带偏;而发现它只要一行卡方、10 秒钟。

🛑 可以停在这里

⚡ 走神救援

⭐ 随机化的全部意义:让两组在你知道的和不知道的因素上都均衡。 「不知道的」那部分是观察性方法做不到的,所以 A/B 才是黄金标准。

六个坑里最贵的两个:peeking(每天看、一跌破 0.05 就宣布,假阳性率会涨到几十个百分点;折中是负向可以早停、正向必须等满)和多重比较(看二十个指标必然有一个显著 → 实验前就写下唯一主指标)。其余:样本量、时长不足一周、SUTVA 违背、只看均值。

⭐ SRM 检查是业界公认最有效的健康检查:说好 50/50 而实际偏离,在大样本下统计上不可能发生,那意味着分流系统有 bug,整个实验不可信。一行卡方就能查。

⭐ 实验前该算的是 MDE 不是样本量(流量通常是给定的)。MDE 比业务上有意义的提升还大,这个实验就别跑。流量不够时用 CUPED:拿实验开始前的同口径指标当协变量,均值不变所以无偏,方差降下来等于缩短实验时长;⚠️ 铁律是 y_pre 只能取实验开始之前。

💀 那个「CTR +6.2%、p=0.003,全量上线后大盘纹丝不动」的案例:唯一被跳过的检查就是 SRM。某老版本 App 解析崩溃,这批用户从实验组分母里消失,而消失的恰好是 CTR 本来就低的人——涨幅全部来自「实验组少了一批差用户」。发现它只要十秒。

下一节 👉 13-不能做AB时的因果推断.md ⭐

打卡记录保存在你的浏览器里,首页能看到总进度