🏠 总目录📚 本教程 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%

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

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 个实验组也一样

三个应对

   ① 提前指定【一个】主指标(primary metric)⭐⭐
      其他都是次要指标,只用来看有没有副作用

   ② 多重比较校正(Bonferroni:阈值除以比较次数)
      简单但保守

   ③ 分层:主指标决定成败,护栏指标只看有没有明显恶化

第 ① 条是最重要的实验纪律实验开始前就写下「我用哪个指标判断成败」。 否则事后从 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 —— 在大样本下这个偏差在统计上是不可能的,说明分流系统有 bugSRM 一旦出现,整个实验结果都不可信,必须先修分流。 这是业界公认最有效的实验健康检查,一行代码就能做

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 是黄金标准。坑一样本量:⭐要检测的提升减半→样本量×4;power 不足的损失是把好东西扔掉且你永远不知道。⭐⭐坑二 peeking:每天看、一旦 p 跌破 0.05 就宣布 → 看10天假阳性率约30%(看1次是5%,一直盯趋近100%);实践折中是⭐「负向可以早停,正向必须等满」坑三时长:至少一个完整周;⚠️新奇效应前三天+8%、一周后+2%、一月后0 → 看逐日曲线而非累计值坑四多重比较:看20个指标必然有1个"显著" → ⭐⭐实验前就写下唯一主指标(事后挑涨的叫 HARKing,统计上无意义)。坑五 SUTVA 违背:社交/双边市场/共享预算里实验组会影响对照组 → 换成按城市或时间片分流,代价是有效样本变成簇的个数坑六只看均值 → 看分位数和分人群。⭐⭐SRM 检查:设50/50实际50.4/49.6 在大样本下统计上不可能 = 分流系统有bug,整个实验不可信,一行卡方就能查——业界公认最有效的健康检查。⭐实验前该算的是 MDE 不是样本量(流量通常是给定的):每组5千人、基线5% → MDE≈1.23个百分点,要涨25%才测得出MDE 比业务上有意义的提升还大就别跑这个实验。⭐⭐流量不够时用 CUPED:用这批用户实验开始前的同口径指标当协变量,把个体差异从方差里扣掉,均值不变所以无偏、方差降低正好 ρ²ρ=0.6 → 方差降36% → 两周实验缩到9天,改十行代码的事却常年被忽略;⚠️铁律:y_pre 只能取实验开始之前,否则等于把实验效果本身扣掉了(实验版特征穿越)。💀真实案例:新排序模型 CTR +6.2%、p=0.003 全量上线,三个月后大盘纹丝不动 —— 唯一被跳过的检查是 SRM:实验组 1,043,221 vs 对照组 1,051,884,差 0.83%,p≈3e-9;根因是新模型响应多了一个字段,某老版本 App(占4.1%)解析崩溃 → 这批用户没产生曝光日志、从实验组分母里消失,而消失的恰好是 CTR 本来就低的低活跃用户+6.2% 全部是"实验组少了一批差用户"。代价:后续5个实验全建立在错误基线上,三个月方向被带偏;而发现它只要一行卡方 10 秒。⭐SRM 排查顺序:分层看哪一层出问题 → 有没有一组用户根本没上报日志 → 哈希有没有加实验名的盐 → 中途改过分流比例 → 过滤条件对两组不对称(如"只看有曝光的用户") → 爬虫流量。修好之前不要看任何结论。

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

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