A/B 实验 SRM 排查手册:从「p 值很干净但桶数不对」倒推到分流链路的五个洞
结论先行:SRM(Sample Ratio Mismatch,样本比例不匹配)不是统计异常,而是系统故障的报警。只要实际进组人数比例和设计比例出现显著偏差,所有基于该实验的指标结论(无论多显著)都应视为不可信——因为偏差本身说明分组机制被污染了,后续所有指标都继承了同一个偏差源。本文给一套可直接复用的排查路径:卡方检验怎么算、阈值怎么设、五类根因按什么顺序查、以及一段我们实际跑在 CI 里的诊断脚本。
一、现象:实验跑完,指标显著,但桶数对不上
一次真实的排查起点:
- 实验:搜索结果页排序策略调整,设计分流 50/50,观察期 14 天;
- 结果读数:实验组人均点击数相对提升 +1.8%,p = 0.002,统计上很漂亮,产品准备推全;
- 复核时发现:对照组进组 UV 4,982,113,实验组 5,041,726,比例 49.71% / 50.29%。
50/50 设计下,49.71% 看起来「差一点点」,直觉上可以忽略。但按二项分布算:总样本 n ≈ 1002 万,p = 0.5 时标准差约 1594 人,实际偏差 29,806 人,偏离均值 18.7 个标准差——这不是抽样波动,是分流系统里真的丢了/多计了约 3 万人。
规模参照:我们一个季度跑了 22 个实验,其中 5 个(22.7%)出现 SRM,偏差幅度 0.1%~0.9%。这 5 个实验里 4 个的「显著结论」在修复分流后重跑中消失或反向。也就是说:SRM 不只让单个实验失效,它系统性地制造了假显著。
二、原理:为什么 SRM 一旦出现,结论必须作废
- SRM 的含义是「分组过程不再是独立随机」。理想 A/B 中每个用户进哪个桶是独立伯努利试验,实际计数围绕期望值波动,波动幅度由 √n 决定。一旦观测偏差远超 √n 量级,说明存在某个机制在非对称地把用户推向某一桶(或把某一桶的用户丢掉)。
- 非对称性 = 选择偏差。被系统性多计/少计的那部分用户,往往不是随机人群,而是某一类有共同特征的人(某客户端版本、某地区、某类请求路径)。指标差异此时混杂了「策略效应 + 人群结构效应」,无法分离。
- 为什么它特别擅长制造假显著:偏差通常集中在少数高/低活人群,而这批人恰好是指标方差最大的部分,于是指标差被放大、p 值被压小。我们复盘那 4 个假显著实验时,偏差集中的用户段(某 iOS 大版本)的人均点击数正好是全局均值的 2.3 倍。
三、具体做法:检验、阈值与排查顺序
1. 卡方检验(最简、够用)
对二分组:
from scipy.stats import chisquare
def srm_check(n1: int, n2: int, p1: float = 0.5, alpha: float = 1e-9) -> dict:
n = n1 + n2
expected = [p1 * n, (1 - p1) * n]
stat, p = chisquare([n1, n2], fexp=expected)
return {"observed_ratio": n1 / n, "chi2": stat, "p": p, "srm": p < alpha}
阈值不要沿用 0.05。SRM 检验的零假设是「分流系统工作正常」,我们是在用这个检验保护下游几百个指标检验,漏检的代价远大于误报。实践中建议 alpha = 1e-5 ~ 1e-9:流量越大越该收紧——1000 万样本下 1e-5 对应约 0.012% 的比例偏差就能触发,而 0.05 要到约 0.06% 偏差才触发,漏掉的正是最危险的小偏差。
2. 五类根因的排查顺序(按我们命中率排序)
按半年内 5 次 SRM 事故的实际归因频率排序:
- 进组日志丢失/重复(命中率最高,5 次中 3 次)。典型形态:客户端 SDK 埋点重试导致实验组多计;或服务端日志采集链路对某机房有 0.3%~0.8% 的丢包,而该机房用户恰好按地域偏分到某一桶。排查:对比「分流决策日志条数」与「指标进组日志条数」,两者差值就是丢失量——我们那次的事故,分流侧 10,023,839 条 vs 指标侧 10,020,951 条,差出的 2888 条全部落在实验组。
- 分流键与业务键碰撞。分流用 device_id,但指标按 user_id 聚合,一个 user 多设备时会把多份曝光算进一个 user,且多设备用户在不同桶的分布不均。排查:
COUNT(DISTINCT user_id)与COUNT(DISTINCT device_id)比值,>1.05 就要警惕。 - 时间窗口截断不对称。实验中途某桶的放量配置被改过(哪怕改回原值),或某机房发布重启导致进组日志断档 10 分钟。排查:按小时画进组 UV 曲线,两桶曲线应近似平行,出现「剪刀口」即有断档/改配。
- 用户被两个实验同时命中且互斥组配置错误。分层实验里互斥层配置漏了某实验,同一用户被双记。排查:抽样 1 万 user,跑互斥校验 SQL,预期命中数为 0。
- 分桶键本身有偏(最低频但最隐蔽)。按 uid 尾号/注册时间分桶时,桶内人群结构天然不同。这里必须引用 @沈寒山 在《灰度放量按 uid 尾号分桶》里的结论:「灰度组 CTR 掉 30% 其实是用户结构差异,不是新链路差」——分桶键与业务属性相关时,5% 桶装的根本不是随机的 5%。分流键一律用无状态哈希(如 murmurhash3(seed, user_id) % 1000),不要用取模尾号、不要用注册时间、不要用客户端自报字段。
3. 一段可直接跑的完整诊断(我们放在每日离线任务里)
-- step1: 进组人数 + SRM
SELECT bucket, COUNT(DISTINCT user_id) AS uv
FROM experiment_enroll WHERE experiment_id = 7821 AND ds BETWEEN '20250101' AND '20250114'
GROUP BY bucket;
-- step2: 分桶键碰撞检查
SELECT COUNT(DISTINCT user_id) / COUNT(DISTINCT device_id) AS user_device_ratio
FROM experiment_enroll WHERE experiment_id = 7821;
-- step3: 小时粒度进组曲线(找剪刀口)
SELECT bucket, hour, COUNT(DISTINCT user_id) AS uv
FROM experiment_enroll WHERE experiment_id = 7821
GROUP BY bucket, hour ORDER BY hour;
把 step1 的 uv 喂给上面的 srm_check,任何一项报警就冻结该实验的结论出口。
四、踩过的坑与反直觉之处
- 「偏差很小,可以接受」是最贵的错误。0.1% 的比例偏差在千万级流量下足以把人均指标的检验功效扭曲出 1~2 个假显著。SRM 的阈值要按「保护全部下游结论」来设,不是按「这个偏差看着不大」来设。
- SRM 报警了但找不到根因,也不能「将就出结论」。我们有一次查了 4 天没定位到丢失源,最后选择废弃实验重跑,而不是打折扣解读——重跑成本 2 周,远小于拿一个污染数据做的产品决策。
- AA 实验是分流系统最好的日常体检:每周跑一个空策略 AA 实验,同时监控 SRM 和指标虚显著率。我们的 AA 实验里指标假阳性率长期在 4.6%(≈0.05 理论值),SRM 从未报警——一旦某周 AA 里 SRM 响了,说明分流链路正在坏,而不是某个实验在坏。
- 多分组实验里,卡方要用全部 k 组一起算,不能两两配对检验后取最小 p 值——那是又一次多重比较,会把真故障的报警淹在噪声里。
五、结论与可复用清单
- [ ] 每个实验出结论前,先跑 SRM 卡方检验,alpha ≤ 1e-5(大流量收紧到 1e-9);
- [ ] SRM 报警 → 冻结结论 → 按「日志丢失/重复 → 键碰撞 → 时间断档 → 互斥冲突 → 分桶键偏差」顺序排查;
- [ ] 分流键一律用带 salt 的无状态哈希,禁用 uid 尾号 / 注册时间 / 客户端自报字段;
- [ ] 每周一个 AA 实验做分流链路体检,同时盯 SRM 与指标假阳性率;
- [ ] 分流决策日志与指标进组日志分开落盘,两者差值就是丢失量,是最快的定位手段;
- [ ] 查不出根因就废弃重跑,不做「打折解读」。
一句话:指标显著性回答「有没有效应」,SRM 回答「这个实验的数据还值不值得信」——后者不过关,前者一律作废。