Victor's Code Journey
Victor's Code Journey

目录

异常维度定位:Adtributor 的意外性与解释力

好物优选的 GMV 从 2000 万掉到 1400 万,一个月掉了 30%。

数据团队按三个维度各拆了一遍,每个维度都给出了"主因":

维度贡献度第一该维度的"主因"
渠道推荐 50.00%推荐
品类服饰 50.00%服饰
城市线级一线 50.00%一线

三个头名都是 50%——各自扛下了跌幅的一半。排查资源按这个顺序排了下去:先查推荐,再查服饰,再查一线。

结果推荐这边查了两天,什么都没查出来:素材没换、召回没动、排序模型没发版、AB 实验也没开。推荐一切正常。

问题出在哪?

**贡献度回答的是"谁造成的变化大",而异动归因要找的是"谁反常"。**这是两个不同的问题,排序规则也就完全不同。上面那张表之所以把"推荐"推上第一,不是因为推荐反常,而是因为推荐本来就占大盘 35%——它跌得多,理应贡献得多。

这是全系列第 07 篇,讲 Adtributor 这个算法。它不按贡献度排序,而是按意外性排序:一把尺子量反常程度,一把尺子量体量,两把一起用。

章节回答什么问题核心结论
一按贡献度排序错在哪三个盲区:体量霸榜、组合爆炸、小而反常被埋没
二怎么量"反常"JS 散度:比的是分布形状,不是绝对量
三怎么量"体量"解释力 EP,同维度内加总 100%
四算法怎么跑三步走:算 S → 生成候选集 → 维度排序
五拿本期数据实算渠道维度最反常的是搜索,不是推荐
六预期值从哪来四个层级,以及它和异动检测的接口
七衍生指标怎么算反事实 + 归一化
八四个坑0 值、单维度、结构重组、维度粒度
九全系列收官七种方法的分工与成本

贡献度 Cᵢ = Vᵢ / ΔY,分子是某个因子自己的变化量,分母是这次变化的总量。这等于把体量和异常揉进了同一个数。

推论很直接:**大盘里占比越大的维度,波动一下贡献度就越高。**推荐的预期占比是 35%(700 万 / 2000 万),它跌了 300 万,贡献度自然冲到 50%。但这恰恰是"它本来就大"的正常表现——一个占三分之一盘子的东西,少掉的量当然多。

反过来看直播:预期 300 万,实际 200 万,绝对贡献同样是 −100 万,贡献度 16.67%。听起来"没那么重要",可它自身跌了 33.3%,大盘跌 30%,它基本是跟着大盘一起走的,没有任何异常。

把贡献度当异常探测器用,等于在排序里混进了一个"体量"因子。体量大的维度天然霸榜,体量小的维度再怎么反常也挤不上来。

真实业务里维度不是三个,是几十个:渠道、品类、城市线级、终端、新老客、会员等级、支付方式、履约方式……每个维度各有 10–30 个枚举值。

一旦想看"组合",单元格数量就是乘法:三个维度各 20 个值,组合出 8000 个格子。两个坏结果随之而来。

**一是信号被稀释。**每个组合单元格的贡献度均值量级在 1/8000,排序出来的名次差异完全被噪声淹没,第 5 名和第 50 名可能只差几个基点。

**二是组合的"贡献度"在数学上根本不成立。**同一个用户既可能被算进"搜索 × 一线 × 服饰",也可能被算进"推荐 × 二线 × 食品"。各组合的贡献度加起来不会等于 100%,因为发生了重叠计数。这不是一个分解,它根本不是一个可加的分解。

第三种情况更隐蔽。一个只占大盘 1% 的枚举值翻了倍,绝对变化只有大盘的 1% 量级,贡献度排在榜尾——但它是唯一一个真的出问题的。

反过来也可能:一个占大盘 40% 的枚举值跌了 30%,贡献度榜一,但它只是跟着大盘走。

在贡献度排名里,这两类是完全无法区分的。

贡献度视角归因视角
想回答谁造成的变化大谁的行为偏离了预期
排序依据绝对变化量占比相对预期的偏移
体量大的维度天然靠前不再有优势
体量小但反常的永远看不见会被顶到前面
flowchart TB
    A["维度归因:GMV 掉了 30%"] --> B["盲区一
占比大的维度天然霸榜"] B --> C["盲区二
维度组合爆炸,排序变成噪声"] C --> D["盲区三
绝对量小但相对变化大被埋没"] D --> E["核心转变
贡献度答「谁造成的变化大」
归因要找的是「谁反常」"]

Adtributor 的第一步动作是把问题换掉。不问"这个枚举值掉了多少",而问:

这个枚举值在大盘里的占比,和预期相比变了多少?

占比的偏移不受体量影响。一个只占 1% 的枚举值,如果占比翻倍,它的占比偏移和一个大枚举值的占比偏移在同一个尺度上,可以直接比。

把预测分布和实际分布各写成一组占比。对维度 i 下的枚举值 j:

p = F_ij / F     预测占比
q = A_ij / A     实际占比

两个分布之间的 Jensen-Shannon 散度:

D_JS(P, Q) = ½ [ Σ pᵢ log₂(2pᵢ/(pᵢ+qᵢ)) + Σ qᵢ log₂(2qᵢ/(pᵢ+qᵢ)) ]

单个枚举值的意外性,就是 JS 散度在这个枚举值上的贡献:

S_ij = ½ [ p log₂(2p/(p+q)) + q log₂(2q/(p+q)) ]

有个顺手的性质:**一个维度所有枚举值的 S 之和,正好等于该维度预测分布与实际分布之间的 JS 散度。**所以 S_ij 既可以单独读(这个枚举值有多反常),也可以汇总读(这个维度的整体分布形状变了多少)。

相对熵(KL 散度)是度量两个分布差异的更古老的工具,但它在这个场景有两个硬伤。

第一,KL 散度不对称。KL(P‖Q) ≠ KL(Q‖P),方向反了数值就变了。而"实际和预期差多少"这件事,在业务上是对称的——预期 40% 变实际 50%,和预期 50% 变实际 40%,应该是同一个严重程度。

**第二,KL 散度在零值处未定义。**只要有一个 pᵢ = 0 而 qᵢ > 0(或反过来),KL 散度就是无穷大。业务里某个枚举值为 0 太常见了:某个渠道当天没量、某个品类断货、某个广告主暂停投放。

JS 散度把两个问题都绕开了:它对称,并且在零值处有限(pᵢ = 0 时那一项按极限取 0,剩下的一半仍然可算)。另外它的值域有界 [0, 1],跨维度比较时不需要归一化——这一点在维度排名时很关键。

KL 散度JS 散度
对称性不对称,KL(P‖Q) ≠ KL(Q‖P)对称
零值处理pᵢ=0 且 qᵢ>0 时为无穷大有限,按极限取 0
值域无界[0, 1]
跨维度可比需要归一化直接可比

它衡量两个分布"重叠得有多差"。

把预测分布和实际分布各自当成一堆概率质量,JS 散度问的是"这两堆东西错位得有多厉害"。取 0 表示两个分布完全一致;越大表示分布形状完全变了,极端情况下(p 和 q 完全不重叠)取 1。

因为公式里用的是 log₂,量纲是比特。所以 S 值都是 0.004016 这种小数——**别把它读成 0.4%,它没有任何百分比含义。**要用就用它的相对排序:同一个维度内谁比谁更反常,不同维度之间比大小(因为值域有界)。

EP_ij = (A_ij − F_ij) / (A − F)

这就是第一篇 讲的贡献度在 Delta 法下的样子:分子是该枚举值自己的变化量,分母是大盘总变化量。前提是指标在这个维度上可加——第一篇 3.1 节讲过,可加性不成立时这个式子直接没有意义。

Σ_j EP_ij = Σ_j (A_ij − F_ij) / (A − F) = (A − F) / (A − F) = 1

因为分母对同一维度内的所有枚举值都是同一个,分子可以直接整体求和。这是 EP 最重要的性质:它是一把体量尺,告诉你这个枚举值能解释多少总变化。

两个边界情况要记住:EP 可以为负(该枚举值与大盘反向变动),也可以超过 100%(它自己变化的幅度超过了大盘总变化)。

尺子记号回答的问题特点
意外性S_ij这个枚举值的行为有多反常只看分布形状,与体量无关;同维度内不保证加总为 100%
解释力EP_ij这个枚举值能解释多少总变化与体量强相关;同维度内加总恰好 100%

单用 EP 会退化成贡献度排序(老问题),单用 S 会选出一堆"占比抖了一下、体量微不足道"的小枚举值。候选集的筛选必须同时用两个:单项 EP 太低的不进候选(解释不了多少),S 太低的也不该进(一点都不反常)。

flowchart TB
    A["输入:各维度枚举值的预期 F 与实际 A"] --> B["第一步
算每个枚举值的意外性 S"] B --> C["第二步
维度内按 S 降序遍历"] C --> D["EP 不低于 T_EEP 才加入候选集"] D --> E["累计 EP 达到 T_EP 时停止"] E --> F["第三步
各维度按候选集 S 之和排序"]

对每个维度 i 下的每个枚举值 j,需要三个数:

  • F_ij:预期值,来自异动检测模块的预测
  • A_ij:实际值
  • F、A:大盘的预期值与实际值(在每个维度上都是同一组数)

对所有维度、所有枚举值算一遍 S_ij,用 2.2 的公式。

在每个维度内部,按 S 从大到小遍历枚举值,做两件事:

  1. 若 EP_ij ≥ T_EEP(单个元素至少要解释这么多),把它加入该维度的候选集
  2. 累计 EP 达到 T_EP 时停止遍历

这两个阈值各管一件事:

  • T_EEP 管"候选集里能塞几个元素"(奥卡姆剃刀)——单个元素解释不了多少,就别往里塞
  • T_EP 管"候选集要解释掉多少"——解释到这个比例就收工

每个维度输出一个候选集,按候选集内枚举值的 S 之和排序,取前几个输出。

**这里有个必须钉死的口径:分子只能是候选集内枚举值的 S 之和。**有的资料在某个维度上误用了"该维度所有枚举值的 S 之和",会把没入选的枚举值一并计入,维度之间的排名会直接错乱。第五节有具体对照。

参数常用取值调高的效果调低的效果
T_EP0.6 – 0.8候选集更短,结论更简洁候选集更长,线索更全
T_EEP0.05 – 0.1单元素门槛更高,候选集更短单元素门槛更低,候选集更长

Adtributor 原文的部署取值是 T_EP = 0.67、T_EEP = 0.10,这两个数字来自排查专家手工流程的习惯值。

调高得到"核心结论",调低得到"候选线索"。排查工单适合高阈值(要一个能立刻开工的结论),定期巡检适合低阈值(宁可多给线索)——两个场景共用一个阈值是最常见的误配。

大盘:预期 2000 万 → 实际 1400 万,Δ = −600 万。以下金额单位均为万元。

渠道维度:

渠道预期 F实际 AΔ
搜索800700−100
推荐700400−300
直播300200−100
私域200100−100
渠道预测占比 p实际占比 qSEP
搜索40.00%50.00%0.00401616.67%
推荐35.00%28.57%0.00234950.00%
直播15.00%14.29%0.00006316.67%
私域10.00%7.14%0.00172616.67%

品类维度:

品类预期 F实际 AΔ
家电1000800−200
服饰600300−300
食品400300−100
品类预测占比 p实际占比 qSEP
家电50.00%57.14%0.00171933.33%
服饰30.00%21.43%0.00517750.00%
食品20.00%21.43%0.00017816.67%

城市线级维度:

城市线级预期 F实际 AΔ
一线1200900−300
二线500300−200
三线及以下300200−100
城市线级预测占比 p实际占比 qSEP
一线60.00%64.29%0.00053350.00%
二线25.00%21.43%0.00099233.33%
三线及以下15.00%14.29%0.00006316.67%

按 T_EEP = 0.10、T_EP = 0.60 跑一遍:

维度遍历顺序(按 S 降序)入选枚举值累计 EP停止原因
渠道搜索 → 推荐 → 私域 → 直播搜索、推荐66.67%达到 T_EP
品类服饰 → 家电 → 食品服饰、家电83.33%达到 T_EP
城市线级二线 → 一线 → 三线及以下二线、一线83.33%达到 T_EP

三个维度都没遍历完就停了。注意私域:它的 EP 是 16.67%、够得上 T_EEP,排在搜索和推荐之后——但因为推荐已经把累计 EP 推到 66.67%,它根本没轮到。

渠道维度:EP 最高的是「推荐」,但 S 最高的是「搜索」。

搜索自己只跌了 12.5%(800 → 700),绝对量上是四个渠道里跌得最轻的,贡献度只有 16.67%。可它偏偏是最反常的那个——大盘跌了 30%,而搜索的占比从 40% 升到了 50%。

怎么理解这件事?**在一个整体收缩 30% 的盘子里,一个只跌了 12.5% 的板块,份额反而涨了 10 个百分点。**这只有两种解释:别的东西塌得比它更狠,或者这个板块被大盘的结构性变化推了上来。无论哪种,搜索都在做一件"大盘做不出来"的事,这正是要查的信号。

推荐则是另一回事。它跌了 42.9%,比大盘还狠 12.9 个百分点,占比从 35% 掉到 28.57%,反常程度排第二(S = 0.002349)。它是这次下跌里体量最大的贡献者,但它的下滑幅度仍然在"能解释"的范围内——贡献度第一不等于最反常。

城市线级:EP 最高的是「一线」,但 S 最高的是「二线」。

二线自己跌了 40%(500 → 300),比大盘还狠,占比从 25% 掉到 21.43%。一线跌了 25%(1200 → 900),比大盘轻,占比反而从 60% 升到 64.29%。所以一线虽然贡献了一半的跌幅(体量最大),它的行为却比大盘更"抗跌",反常程度反倒低于二线。

品类维度:服饰 EP 最高,S 也最高,两者一致。

服饰自己跌了 50%,是实打实的问题。家电跌了 20%(比大盘轻),占比从 50% 升到 57.14%,EP 33.33% 全部来自体量。食品跌了 25%,占比几乎没动,S = 0.000178。

**直播的 S 近乎 0(0.000063)。**它预期 300 实际 200,跌了 33.3%,跟大盘的 30% 基本同步,占比从 15% 只滑到 14.29%。分布形状几乎没变,所以 S 几乎为零。这类枚举值不该浪费排查资源——它的变化完全可以用"大盘变了"解释掉。

三个维度的对照:

维度EP 最高者S 最高者是否一致结论
渠道推荐 50.00%搜索 0.004016否搜索才是最反常的那个
品类服饰 50.00%服饰 0.005177是服饰是实打实的问题
城市线级一线 50.00%二线 0.000992否二线更反常
维度候选集候选集 S 之和
品类服饰、家电0.006895
渠道搜索、推荐0.006364
城市线级二线、一线0.001525

结论是:**先查品类。**服饰是这个维度里 EP 和 S 双高的枚举值,它自己跌了 50%,比大盘的 30% 深得多,是本期最硬的问题。渠道维度紧随其后,但要看的是搜索而不是推荐。

**一个必须点出的口径陷阱。**如果把"候选集 S 之和"误算成"该维度所有枚举值的 S 之和",渠道维度会变成 0.008153(0.004016 + 0.002349 + 0.001726 + 0.000063),品类维度变成 0.007073,排名直接翻转成渠道第一。差别来自没进候选集的私域(0.001726)和直播(0.000063)——它们被算了进去,扭曲了两个维度的可比性。

核心逻辑是"算 S → 按 S 降序生成候选集 → 汇总",几十行代码:

import math

EPS = 1e-12


def surprise(p: float, q: float) -> float:
    """意外性 S_ij:JS 散度在单个枚举值上的贡献(log 取 2 为底)"""
    if p + q <= EPS:                          # 预期与实际都为 0,该项没有信息
        return 0.0
    return 0.5 * (p * math.log2(2 * p / (p + q)) + q * math.log2(2 * q / (p + q)))


def explain_power(f: float, a: float, F: float, A: float) -> float:
    """解释力 EP:这个枚举值能解释多少总变化,同维度内求和等于 100%"""
    return (a - f) / (A - F)

两把尺子算完之后,剩下的是候选集的生成与汇总:

def analyze(forecast: dict, actual: dict, t_eep=0.10, t_ep=0.60) -> dict:
    """单个维度跑完整流程:算 S → 按 S 降序生成候选集 → 汇总"""
    F, A = sum(forecast.values()), sum(actual.values())   # 大盘预期与实际
    rows = []
    for k, f in forecast.items():
        a = actual[k]
        p, q = f / F, a / A                              # 预测占比 / 实际占比
        rows.append({"k": k, "p": p, "q": q,
                     "S": surprise(p, q),
                     "EP": explain_power(f, a, F, A)})
    cand, cum_ep, cand_s = [], 0.0, 0.0
    for r in sorted(rows, key=lambda r: -r["S"]):         # 维度内按 S 降序遍历
        if r["EP"] < t_eep:                              # 单项解释力太低,不进候选
            continue
        cand.append(r["k"]); cum_ep += r["EP"]; cand_s += r["S"]
        if cum_ep >= t_ep:                              # 解释力够了,收工
            break
    return {"rows": rows, "cand": cand, "cum_ep": cum_ep, "cand_s": cand_s}

套上三个维度的数据:

dims = {
    "渠道": ({"搜索": 800, "推荐": 700, "直播": 300, "私域": 200},
             {"搜索": 700, "推荐": 400, "直播": 200, "私域": 100}),
    "品类": ({"家电": 1000, "服饰": 600, "食品": 400},
             {"家电": 800, "服饰": 300, "食品": 300}),
    "城市线级": ({"一线": 1200, "二线": 500, "三线及以下": 300},
                 {"一线": 900, "二线": 300, "三线及以下": 200}),
}
out = {name: analyze(fc, ac) for name, (fc, ac) in dims.items()}   # 三个维度各跑一次
for name, r in out.items():
    for row in sorted(r["rows"], key=lambda x: -x["S"]):
        print(f"  {name} · {row['k']:<6s} p={row['p']:>7.2%} q={row['q']:>7.2%} "
              f"S={row['S']:.6f}  EP={row['EP']:>7.2%}")
    print(f"  -> 候选集 {r['cand']}  累计 EP {r['cum_ep']:.2%}  候选集 S 之和 {r['cand_s']:.6f}\n")

print("维度排名(按候选集 S 之和)")
for name, r in sorted(out.items(), key=lambda x: -x[1]["cand_s"]):
    print(f"  {name}: {r['cand_s']:.6f}")

输出:

  渠道 · 搜索     p= 40.00% q= 50.00% S=0.004016  EP= 16.67%
  渠道 · 推荐     p= 35.00% q= 28.57% S=0.002349  EP= 50.00%
  渠道 · 私域     p= 10.00% q=  7.14% S=0.001726  EP= 16.67%
  渠道 · 直播     p= 15.00% q= 14.29% S=0.000063  EP= 16.67%
  -> 候选集 ['搜索', '推荐']  累计 EP 66.67%  候选集 S 之和 0.006364

  品类 · 服饰     p= 30.00% q= 21.43% S=0.005177  EP= 50.00%
  品类 · 家电     p= 50.00% q= 57.14% S=0.001719  EP= 33.33%
  品类 · 食品     p= 20.00% q= 21.43% S=0.000178  EP= 16.67%
  -> 候选集 ['服饰', '家电']  累计 EP 83.33%  候选集 S 之和 0.006895

  城市线级 · 二线     p= 25.00% q= 21.43% S=0.000992  EP= 33.33%
  城市线级 · 一线     p= 60.00% q= 64.29% S=0.000533  EP= 50.00%
  城市线级 · 三线及以下  p= 15.00% q= 14.29% S=0.000063  EP= 16.67%
  -> 候选集 ['二线', '一线']  累计 EP 83.33%  候选集 S 之和 0.001525

维度排名(按候选集 S 之和)
  品类: 0.006895
  渠道: 0.006364
  城市线级: 0.001525

这是本篇与"异动检测"的接口。前面几篇的方法只需要两期实际值,Adtributor 需要的是预期值 + 实际值——预期值从哪来属于统计异动检测的范畴,本系列不展开,这里只交代四个层级。

层级做法适用场景代价
① 基期当预期F = 上期值指标平稳、无趋势把任何变化都当异动
② 同比 / 环比F = 去年同期 / 上月季节性不强的指标忽略季节性与大促日历
③ 季节性预测用历史同期预测,考虑周中低周末高等规律有明显周期的指标需要足够多的同期历史
④ 时间序列模型ARMA / ARIMA 等复杂季节性、趋势建模成本最高

**①是 ARIMA 的退化形式。**朴素预测相当于把模型阶数取 0,丢掉了一切结构信息。之所以常用,是因为它零成本,并且在小波动场景下够用。

**③是性价比最高的一层。**电商指标普遍有"周中低周末高"和"大促脉冲"两种周期性,用历史同期做基线,往往比通用时间序列模型更准,也更容易向业务解释。

**④是 Adtributor 原文的做法。**原文把预期值交给一个基于 ARMA 的模型预测模块,它基于 8 周历史数据生成预测,并显式考虑了时段(time-of-day)与周内(day-of-week)的正常波动。

第一篇 的五步 SOP 里,第 ③ 步之后才进入场内归因。预期值从哪来,属于第 ③ 步之前的检测环节:Adtributor 是本系列第一个依赖预测值的方法,也正因为如此,它才有能力在几百个维度里"搜索"问题。

3.2 节那个 EP 公式只对可加指标成立。比率型(转化率、ROI、CPC)和乘法型(人均消费、GPM)衍生指标都不满足"各部分相加 = 整体",原始定义直接失效,需要换一套构造。

核心思想一句话:假设只有这一个枚举值变了,其它全部保持预期值,衍生指标会变成多少?

设衍生指标 M = N / D,枚举值 j 的反事实值为:

M_j^cf = (F(N) + ΔN_j) / (F(D) + ΔD_j)

其中 ΔN_j = A_j(N) − F_j(N),ΔD_j = A_j(D) − F_j(D)

分子分母都是"总体预期值 + 该枚举值自己的偏离",等价于"其它枚举值全部停在预期值,只有 j 取实际值"。

单因子影响与归一化:

ΔM_j = M_j^cf − M⁰        M⁰ = F(N) / F(D) 是整体预期值
EP_j = ΔM_j / Σ_k ΔM_k

注意 Σ_k ΔM_k 并不等于实际的 ΔM,差额是交互项——归一化正是为了消掉这个差额,让同维度加总恰好 100%。

flowchart TB
    A["衍生指标 M = N / D"] --> B["反事实:只让 j 的原子取实际值
其余全部保持预期值"] B --> C["得到 M_j 的反事实值"] C --> D["单因子影响 ΔM_j = M_j 反事实 − M 预期"] D --> E["归一化:EP_j = ΔM_j / Σ ΔM_k"]

举例。三个渠道的转化率数据:

渠道访客数 F访客数 A支付用户数 F支付用户数 A
搜索8007008077
推荐7004007028
直播3002003016
合计18001300180121

整体转化率:预期 180 / 1800 = 10.00%,实际 121 / 1300 = 9.31%,Δ = −0.69pp。逐个做反事实:

渠道反事实转化率ΔM_j归一化 EP
搜索(180−3)/(1800−100) = 10.41%+0.41 pp−66.0%
推荐(180−42)/(1800−300) = 9.20%−0.80 pp+128.3%
直播(180−14)/(1800−100) = 9.76%−0.24 pp+37.7%

读法:推荐的转化率从 10% 掉到 7%,单它一个就把整体转化率拖低 0.80pp,EP 128.3%——比 100% 还大,因为搜索的转化率从 10% 升到 11%,反向把整体往回拉了 0.41pp。归一化把这种正负对冲压到了同一口径上,加总恰好 100%。

这个构造与 Adtributor 原文的有限差分公式完全等价。原文对 m1/m2 形式的衍生指标给出的形式是:

EP_ij = [ΔA_ij(m1)·F(m2) − ΔA_ij(m2)·F(m1)] / [F(m2)·(F(m2) + ΔA_ij(m2))]

把反事实式展开,分子分母化简之后正是这一串。

M = X₁ · X₂ · ... · X_k,反事实更直接:只把第 j 个原子换成实际值,其余留在预期值。

M_j^cf = X_j¹ · Π_{i≠j} X_i⁰
ΔM_j = M_j^cf − M⁰
EP_j = ΔM_j / Σ_k ΔM_k

举个小例子。整体客单价 = 订单数 × 单件价格:

原子指标预期实际
订单数1000850
单件价格(元)2022
原子指标反事实客单价ΔM_j归一化 EP
订单数850 × 20 = 17000−3000+300.0%
单件价格1000 × 22 = 22000+2000−200.0%
合计−1000100%

整体客单价从 20000 掉到 18700,跌了 1300。订单数单它一个会把客单价砸到 17000(−3000),是总跌幅的两倍多;单件价格则是正向的(+2000)。归一化之后,订单数拿到 300%、单件价格拿到 −200%。

**EP 大于 100% 和小于 0%,都出现在这里。**它们不是计算错误,正是"多个原子对冲"时必然出现的形态——第二篇 讲残差时说过一次。

衍生指标的意外性没法像 EP 那样直接反事实——占比 p、q 是相对于衍生指标自身的分布算的,而衍生指标在维度内部根本不是可加的。

Adtributor 原文走的是相对熵的联合分布路线:如果各原子指标相互独立,那么联合分布的 JS 散度等于各原子指标 JS 散度之和。于是衍生指标的意外性近似为:

S(衍生指标) ≈ Σ_k S(原子指标 k)

**这是一个工程近似,不是严格推导。**原文明确承认指标之间往往相关(搜索量涨,广告收入通常跟着涨),这个加法在相关时会失真。

工程上的处理办法是按相关性给各原子指标配权重,而不是简单相加。相关性矩阵可以从历史数据上估(各原子指标变动的滚动相关系数),权重按相关程度打折。

这是本文里最需要工程判断的一环。衍生指标和它的原子指标强相关时,S 的简单相加会高估意外性,进而把一个实际上"随大流"的维度顶进候选集。

JS 散度里有两处会碰到 0。

某个枚举值的 p 或 q 为 0。log₂(2p/(p+q)) 在 p = 0 时是 log₂(0),数学上未定义。正确的处理是按极限取 0(因为 0·log 0 的极限是 0),整体不会炸。

**整体 A = 0。**此时 q = A_ij / A 全部未定义,整个维度失去意义。大盘实际值为 0 时不能做归因。

工程上统一用 x → max(ε, x) 兜底,ε 取 1e-9 量级。但要清楚这只是在数值上避免除零,不是数学上的正确答案——p = 0 的枚举值在业务上往往本身就是重要信息(“这个渠道当天完全没量"比"这个渠道跌了 1%“严重得多),最好在代码里显式区分"p = 0 且 q > 0”(冒出一个从未有过的枚举值,是强信号)和"p = q = 0”(两边都没有,无信息)。

算法的输出是"一个维度 + 该维度的一组枚举值",它不输出维度组合。

想看"地域 × 渠道"的组合怎么办?把组合本身当成一个新维度——枚举值从"一线 × 搜索"到"三线 × 私域",重新跑一遍。代价是单元格数量翻倍、噪声更大,通常只对已经锁定的那一个维度做这种二次下钻。

有一类情况会让 EP 变得很难读:大盘的变化量 (A − F) 很小,但维度内部结构大变。

比如大盘只跌了 1%,而某个枚举值的占比从 40% 掉到 20%。此时 EP 的分母 (A − F) 极小,算出来的 EP 会是几百上千这种数值——它没有业务含义,只是分母太小的算术结果。

这类情况要单独判定:先看 |A − F| 相对大盘的量级,如果大盘变化本身就在噪声范围内、而某个维度内部结构剧变,那多半是结构性重组(比如渠道占比整体平移)。这种情况应该换方法——第三篇 的组间组内拆解正是为它准备的。

枚举值该切多细,是业务决策,不是算法决策。

切太粗:几个枚举值合在一起,异常被稀释,S 算不出差别。切太细:每个枚举值的占比都很小,S 被小分母放大,噪声直接淹没有效信号——这正是 1.2 节说的维度组合爆炸。

实用做法是按排查专家能直接对应到工单归属的粒度来切(渠道、城市线级、品类、终端),并且先跑一遍,看 S 的分布是否有一个明显的断层。如果所有 S 值都在同一个数量级里,说明这个维度切得太细,或者这期确实没异常。

flowchart TB
    A["指标异动归因 7 篇"] --> B["第 01 至 06 篇
验证一个你已经知道的假设"] B --> C["大盘跌了,你怀疑某个渠道
去验证它对不对"] C --> D["第 07 篇
在几百个维度里搜索假设"] D --> E["你不知道问题在哪
让算法按反常程度排序"]

第一篇 到第三篇 讲的都是验证一个你已经知道的假设:大盘跌了,你怀疑是某个渠道,去验证它对不对。前提是方向已经有了,方法提供的是"这个结论有多可信"和"它解释了多大比例"。第四篇 到第六篇 把同一套思路分别用到漏斗、多因素叠加和用户路径上,性质一样。

本篇讲的是在几百个维度里搜索假设:你不知道问题在哪,让算法按反常程度把候选排出来。

这个区别对应两种不同的工程形态:

第 01 – 06 篇第 07 篇
出发点已经有怀疑对象不知道问题在哪
输入两期实际值预期值 + 实际值
输出贡献度分解意外性排序 + 解释力
前提指标可加性已判定已有异动检测 + 预期值
成本低中(多维枚举 + 预测)
方法输入回答的问题成本
Delta 法两期各因子值谁造成的变化大最低
LMDI两期 + 乘积式模型各因子的对数变化份额低
组间组内拆解比率型指标 + 分群权重自身性变化还是结构性变化低
链式替代漏斗各环节哪一环坏了低
Shapley 值多因素 + 交互关系各因素的边际贡献中(指数级,需采样)
马尔科夫链完整路径序列去掉某个环节损失多少中
Adtributor预期值 + 实际值 + 多维枚举谁反常中(依赖异动检测)
#篇目这一篇讲什么
01指标异动归因全景框架、词汇、选型地图
02指标分解模型:加法、乘法与残差Delta、LMDI,以及残差从哪来
03比率指标归因:组间组内拆解与辛普森悖论转化率、ROI 这类指标怎么拆
04漏斗拆解:定位是哪一环坏了贡献度和优化优先级不是一回事
05多因素叠加归因:Shapley 值与多触点权重多个因素同时作用,怎么分功劳
06路径归因:从首触末触到马尔科夫链用户走过的路,每一步值多少钱
07异常维度定位:Adtributor 的意外性与解释力在几百个维度里找到真正反常的那个

回到开头那个场景:推荐被标记成"主因",排查了两天一无所获。**它不是主因,它只是这次下跌里体量最大的那个参与者。**真正反常的是搜索——它只跌了 12.5%,份额却从 40% 涨到 50%。

如果把排序口径从"贡献度"换成"意外性",同一个问题当天就能定位到搜索身上。而这不是什么魔法,只是换了一把尺子。

**贡献度回答"谁造成的变化大",归因要找的是"谁反常"。**Adtributor 把排序口径从体量换成分布形状,再用解释力兜住体量——两把尺子一起用,才既不会漏掉小而反常的枚举值,也不会被大而无奇的维度带偏。

如果只能带走五句话:

  1. **贡献度排序和归因排序是两个不同的问题。**前者问"谁造成的变化大",后者问"谁的行为偏离了预期";大盘占比越大的维度贡献度天然越高,跟它反不反常无关。
  2. **意外性用 JS 散度算,比的是分布形状。**p 是预期占比、q 是实际占比,S_ij 衡量这个枚举值的占比偏离预期多少;用 JS 而不是 KL,是因为 KL 不对称、且在零值处未定义。
  3. **意外性必须配解释力一起用。**S 挑出反常的,EP 保证它有体量、能解释总变化;候选集用两个阈值筛选(T_EP 取 0.6–0.8,T_EEP 取 0.05–0.1)。
  4. **维度排序用"候选集内枚举值的 S 之和"。**误用"该维度全部枚举值的 S 之和"会把没入选的枚举值算进去,维度之间的排名会直接错乱。
  5. 衍生指标靠反事实算。“只有这个枚举值变了会怎样"给出单因子影响,归一化后同维度加总 100%;S 的可加性是独立假设下的工程近似,相关性强时要按相关性调权。
  • R. Bhagwan, V. Karamcheti, N. McSherry, V. Sivaraman, Adtributor: Revenue Debugging in Advertising Systems, USENIX NSDI 2014,https://www.usenix.org/system/files/conference/nsdi14/nsdi14-paper-bhagwan.pdf
  • Jensen-Shannon divergence, Wikipedia,https://en.wikipedia.org/wiki/Jensen%E2%80%93Shannon_divergence
  • Kullback-Leibler divergence, Wikipedia,https://en.wikipedia.org/wiki/Kullback%E2%80%93Leibler_divergence

相关内容