结论先行:异动归因最大的坑不是「拆不出来」,而是「拆得太深」。拆到叶子分片时,贡献度排名会被小样本噪声和维度交互项一起带偏,top1 往往指向一个流量占比不足 1% 的冷门分片——你照它去回滚灰度,就是在给噪声背锅。本文给一套可复现的「早停」判据:样本量准入、显著性过滤、贡献度闭合校验,把 217 个叶子分片收敛到 9 个可信根因,闭合率从 0.68 拉到 0.93。
一、现象与背景
去年 Q4 一次大促,我们电商大盘支付转化率从 3.91% 掉到 3.52%(-0.39pp,相对降幅 10%),日均曝光 UV 约 98 万。告警推来后,值班同学按「渠道 × 机型 × 城市」一路拆到叶子,SQL 跑出来 217 个分片,按贡献度 w_i · Δm_i 排序,top1 是「某安卓冷门机型 × 某新客渠道」——它的流量占比只有 0.42%,却贡献了 18% 的跌幅。
业务方按这个结论去查,查了 4 小时才发现:这个机型近 3 天曝光 UV 只有 1900,转化率从 3.1% 掉到 2.2%,绝对差 0.9pp 看着很大,但 1900 的样本量下 0.9pp 完全在二项分布的抖动范围内(1σ 就有约 0.6pp)。真正的原因其实是两个:高转化渠道的流量占比掉了 6pp(结构效应)+ 主力机型自身转化率微降(率效应),这两个在「渠道」和「机型」两层就都看得到,根本不需要拆到叶子。
统计过去 6 个月我们接手的 24 次 P1 级比率指标异动里,11 次(46%)的最终根因其实落在 2 层以内维度,却平均被拆到了 3.4 层,平均定位时长 4.1 小时。拆得深 ≠ 拆得准。
二、原理:为什么越拆越错
先把贡献度写清楚。比率指标 R = Σᵢ wᵢ·mᵢ(wᵢ 流量占比,mᵢ 分片自身率),两时点之差用 Shapley 对称分解(公式与站内 443 的结构/率效应一致,本文不重复推导):
ΔR = Σᵢ (wᵢ₁−wᵢ₀)·(mᵢ₀+mᵢ₁)/2 ← 结构项
+ Σᵢ (mᵢ₁−mᵢ₀)·(wᵢ₀+wᵢ₁)/2 ← 率项
叶子分片「贡献度 = wᵢ·Δmᵢ」排名被带偏,有三个机制:
- 小样本方差被排名放大。mᵢ 是二项比例,分母 nᵢ 越小,
Var(m̂ᵢ) ≈ mᵢ(1−mᵢ)/nᵢ越大。Δmᵢ 的估计误差标准差σ ≈ √(2m(1−m)/n)随 n 缩小而放大,而贡献度排序只看wᵢ·Δmᵢ的大小、不看 Δmᵢ 的置信度——一个 wᵢ=0.004、Δmᵢ 落在 ±1.2σ 噪声里的分片,完全可能排到 top1。 - 维度交互项在叶子层才出现。拆到「渠道×机型×城市」时,
ΔR里混着两两、三三交互,单维贡献度Σ不再闭合于 ΔR(残差项变大),你按单维贡献度排序,等于在用一个「缺了交互项」的模型做排名。 - 多重比较的假阳性。217 个分片同时做差,即便全是噪声,按 |Δmᵢ| 排序也一定有一个「最极端」的——这不是显著性,是选择偏误。拆得越深,这个假 top1 越容易出现。
三、具体做法:三条早停判据(可复现)
核心思路:下钻不是一条路走到黑,而是「每下钻一层,先过三道闸,三道闸全过才允许继续往下拆」。
import numpy as np
from statsmodels.stats.proportion import proportion_confint, proportions_ztest
MIN_SHARE = 0.005 # 样本量准入:分片流量占比 < 0.5% 一律归入「其他」桶
ALPHA = 0.05 # 显著性水平(双侧)
CLOSE_MIN = 0.85 # 闭合率下限:top-k 贡献度 / |ΔR|
TOPK = 9 # 只保留 top-k 进入根因结论
def early_stop(base, now, top_k=TOPK):
"""base/now: DataFrame, 列 = share(流量占比), rate(分片率), n(分母样本量)"""
# 闸 1:样本量准入 —— 小分片合并,避免噪声分片进排名
small = base['share'] < MIN_SHARE
base = _merge_small(base, small) # 把 share<0.5% 的合并成 'OTHER'
now = _merge_small(now, small)
# 闸 2:显著性过滤 —— 对每个分片做双侧 z 检验,CI 跨 0 的剔除
rows = []
for i in base.index:
z, p = proportions_ztest(
[int(now.loc[i,'n']*now.loc[i,'rate']), int(base.loc[i,'n']*base.loc[i,'rate'])],
[now.loc[i,'n'], base.loc[i,'n']])
lo, hi = proportion_confint(
int(now.loc[i,'n']*now.loc[i,'rate']), now.loc[i,'n'],
alpha=ALPHA, method='wilson')
rows.append((i, p, lo, hi))
sig = [r for r in rows if r[1] < ALPHA] # 只留显著分片
# 闸 3:闭合率校验 —— top-k 贡献度之和 / |ΔR|,不达标就停在这一层
contrib = {i: now.loc[i,'share']*(now.loc[i,'rate']-base.loc[i,'rate']) for i in sig}
dR = sum(contrib.values())
topk = sorted(contrib.items(), key=lambda kv: -abs(kv[1]))[:top_k]
closure = sum(v for _, v in topk) / abs(dR) if dR else 0.0
return topk, closure # closure < CLOSE_MIN → 本层交互项大,停止下钻
# _merge_small: 把 share<MIN_SHARE 的行合并,share 求和、rate 按 n 加权、n 求和
def _merge_small(df, mask):
other_share = df.loc[mask,'share'].sum()
other_n = df.loc[mask,'n'].sum()
other_rate = (df.loc[mask,'rate']*df.loc[mask,'n']).sum()/other_n
keep = df.loc[~mask].copy()
keep.loc['OTHER'] = ['OTHER', other_share, other_rate, other_n]
return keep
执行流程(伪代码):
layer = 1
while True:
topk, closure = early_stop(base_at(layer), now_at(layer))
if closure < CLOSE_MIN: # 闭合不达标 → 交互项大,停在这一层
break
if not topk: # 没有显著分片 → 停
break
layer += 1 # 否则沿 topk 中最大的分片继续下钻
本例实测:第 1 层(渠道)闭合率 0.88,top3 全显著;第 2 层(渠道×机型)闭合率 0.93,top9 里 7 个显著;第 3 层(渠道×机型×城市)闭合率掉到 0.68、显著分片只剩 4 个且 top1 还是那个 0.42% 流量的冷门机型——判据在这里触发了「停」,结论停在第 2 层,恰好就是真根因所在层。
四、踩过的坑(反直觉之处)
- 闭合率掉下去 ≠ 数据坏了。第 3 层闭合率 0.68 不是 SQL 写错,是维度交互项在这一层开始主导——把它当 bug 去修,修不出结果。
- MIN_SHARE 不能拍 0.5% 就完事。它要跟指标方差一起调:转化率 3% 左右的指标,
n ≥ 4000才能让 1σ 落在 0.2pp 以内;客单价这类重尾指标,n ≥ 1000都压不住 1σ。我们最终按「1σ ≤ 目标检测精度的一半」反推 MIN_SHARE,大促期把它从 0.5% 提到 1.2%。 - z 检验在 m≈0.5、n 足够时和 Wilson CI 结论一致,但小 n 下 z 偏乐观。我们用 Wilson CI 做闸 2 的兜底,比裸 p 值多拦了约 15% 的假阳性分片。
- 闭合率阈值 0.85 是经验值。它本质是「单维贡献度模型能解释多少 ΔR」——低于它,说明交互项或残差不可忽略,继续拆只会引入更多选择偏误。我们复盘 24 次异动,0.85 这个阈值召回了 22 次的真根因层,另 2 次(跨端 bug)确实需要拆到 4 层才能暴露。
五、结论与可复用清单
- 归因下钻是「过闸式」的,不是「递归式」的:每层过三道闸(样本量准入 → 显著性过滤 → 闭合率校验),全过才继续。
- 小分片先合并再排序:
share < MIN_SHARE一律归「其他」桶,否则 top1 大概率是噪声。 - 贡献度排名必须带显著性:CI 跨 0 的分片不参与 top-k,避免 217 分片里的选择偏误假 top1。
- 闭合率是「能不能再拆」的开关:
Σ top-k 贡献 / |ΔR| < 0.85就停——低于它说明交互项主导,单维模型已失效。 - MIN_SHARE 与目标检测精度绑定:按「1σ ≤ 精度/2」反推,别拍脑袋定 0.5%。
- 配合方法选型一起用:本文的早停判据叠加在反事实分解(见 614)与结构/率效应公式(见 443)之上,三者合起来才是「拆得准 + 拆得停」的完整链路。
一句话:拆到叶子分片,不是「更准」,而是「更容易被噪声骗」。把早停判据写进归因脚本,比多拆两层维度更有价值。