数据, 术→技巧

异常根因分析方法Adtributor

钱魏Way · · 373 次浏览
!文章内容如有错误或排版问题,请提交反馈,非常感谢!

Adtributor算法简介

Adtributor算法是由微软研究院在2014年提出的一种用于多维时间序列异常根因分析的方法。它主要用于解决以下问题:当某个关键性能指标(KPI)发生异常波动时,如何快速准确地找出导致该异常的根本原因。尤其适用于复杂的多维度数据场景。

举个例子,假设一个电商平台的日订单量突然下降,导致订单量下降的原因可能有多种,例如:

  • 时间维度:促销活动结束、节假日影响等。
  • 地区维度:某个或某些地区的订单量大幅下降。
  • 渠道维度:某个或某些推广渠道的效果变差。
  • 商品维度:某个或某些商品的销量大幅下滑。

Adtributor算法能够自动分析这些多维度的数据,找出导致订单量下降的根本原因。

原始论文:《Adtributor: Revenue Debugging in Advertising Systems

核心思想

Adtributor算法基于以下两个核心概念来量化根因:

  • 解释力(Explanatory Power, EP):指某个维度或维度内的某个元素对于KPI异常的解释程度。EP越高,说明该维度或元素越有可能是导致异常的根本原因。
  • 惊奇性(Surprise, S):指某个维度或维度内的某个元素的异常程度。S越高,说明该维度或元素的变化越显著。

Adtributor算法假设所有根因都是一维的,通过计算每个维度的惊奇性(维度内所有元素惊奇性之和)对维度进行排序,从而确定根因所在的维度。然后,再通过分析该维度内各个元素的解释力和惊奇性,找出最终的根因。

数学解释

Adtributor算法的核心在于量化”解释力(Explanatory Power, EP)”和”惊奇性(Surprise, S)”,并通过这两个指标来定位异常的根因。

数据预处理和异常检测

首先,我们需要收集KPI的多维时间序列数据,并进行必要的预处理,例如数据清洗、缺失值填充等。然后,使用时间序列模型(例如ARMA模型)对KPI进行预测。

假设我们要分析的KPI为m,时间点为t

  • 模型预测值:$F_t(m)$
  • 实际值:$A_t(m)$
  • 异常程度(绝对偏差):$|A_t(m)-F_t(m)|$

解释力(Explanatory Power, EP)

解释力(EP)用于衡量某个维度或维度内的某个元素对于KPI异常的解释程度。

假设我们需要分析d个维度,每个维度i有$n_i$个元素。对于维度i中的元素j,其EP值计算公式如下:

$$EP_{ij}(m)=\frac{A_{ij}(m)-F_{ij}(m)}{A_t(m)-F_t(m)}\times100%$$

其中:

  • $A_{ij}(m)$:维度i的元素j上KPI m的实际值。
  • $F_{ij}(m)$:维度i的元素j上KPI m的预测值。
  • $A_t(m)$:KPI m的总体实际值。
  • $F_t(m)$:KPI m的总体预测值。

这个公式的含义是,元素j的异常变化占KPI总体异常变化的百分比。EP值可以是正数、负数或大于100%。需要注意的是,同一维度下所有元素的EP值之和必须为100%。

惊奇性(Surprise, S)

惊奇性(S)用于衡量某个维度或维度内的某个元素的异常程度。Adtributor算法使用以下公式计算维度i中元素j的惊奇性:

$$S_{ij}(m)=|(A_{ij}(m)–F_{ij}(m))/F_{ij}(m)|$$

这个公式计算的是元素j的实际值与预测值之间的相对偏差的绝对值。S值越高,说明该元素的变化越显著,越”令人惊讶”。

对于整个维度i,其惊奇性定义为该维度下所有元素的惊奇性之和:

$$S_{i}(m)=\sum_{j=1}^{n_i}S_{ij}(m)$$

补充说明:EP和S的正负号及特殊情况

  • EP的正负号:
    • 如果$A_{ij}(m)>F_{ij}(m)$且$A_t(m)>F_t(m)$,则$EP_{ij}(m)>0$,表示该元素的变化方向与总体变化方向一致,对异常起促进作用。
    • 如果$A_{ij}(m)<F_{ij}(m)$且$A_t(m)<F_t(m)$,则$EP_{ij}(m)>0$,同样表示该元素的变化方向与总体变化方向一致,对异常起促进作用。
    • 如果$A_{ij}(m)>F_{ij}(m)$且$A_t(m)<F_t(m)$,或$A_{ij}(m)<F_{ij}(m)$且$A_t(m)>F_t(m)$,则$EP_{ij}(m)<0$,表示该元素的变化方向与总体变化方向相反,对异常起抑制作用。
  • EP大于100%的情况:
    • 如果某个元素的变化幅度远超总体变化幅度,其EP值可能大于100%。例如,如果总体下降了100,而某个元素单独就下降了150,那么该元素的EP值就是150%。这种情况表明该元素对异常的贡献非常大。
  • F_{ij}(m)=0的情况:
    • 当$F_{ij}(m)=0$时,$S_{ij}(m)$的计算公式会产生除以零的错误。实际应用中,通常会设置一个很小的正数ε来代替0,即:
    • $S_{ij}(m)=\left|\frac{A_{ij}(m)-F_{ij}(m)}{max(F_{ij}(m),\epsilon)}\right|$,其中ε是一个接近于0的正数,例如1e-6。

根因分析

Adtributor算法假设所有根因都是一维的。首先,根据每个维度的惊奇性$S_{i}(m)$对所有维度进行排序,选择惊奇性最高的维度作为潜在的根因维度。

然后,在该维度内,根据每个元素的解释力$EP_{ij}(m)$和惊奇性$S_{ij}(m)$进行分析,找出最有可能的根因元素。通常来说,EP值绝对值较高且S值较高的元素更有可能是根因。

举例说明

假设某电商平台日订单量(KPI m)突然下降了1000单。我们分析了两个维度:地区(维度1)和商品类别(维度2)。

  • 地区维度:包括北京、上海、广州三个元素。
  • 商品类别维度:包括服装、数码、家居三个元素。

经过计算,得到以下结果:

维度 元素 EP(%) S
地区 北京 60 0.2
地区 上海 30 0.1
地区 广州 10 0.05
商品类别 服装 40 0.5
商品类别 数码 30 0.4
商品类别 家居 30 0.1

根据维度惊奇性$S_{i}(m)$,商品类别维度(S=0.5+0.4+0.1=1.0)高于地区维度(S=0.2+0.1+0.05=0.35),因此我们首先关注商品类别维度。

在商品类别维度中,服装类别的EP值最高(40%)且S值也较高(0.5),因此我们认为服装类别是导致订单量下降的主要原因。

Adtributor算法的局限

  • 根因解释局限于单个维度:未能充分考虑多维度的组合效应。
  • 对整体KPI变化情况的严重依赖:在整体KPI变化不大但内部波动剧烈的数据集上表现不佳。
  • 难以考虑因素之间的关联关系:算法倾向于在单一维度内寻找原因,忽略了不同因素之间的相互作用。

Adtributor算法的Python实现

Adtributor算法的Python实现涉及多个步骤,包括数据预处理、异常检测、EP和S的计算以及根因分析。由于Adtributor并非一个标准的Python库,因此需要自己编写代码来实现。

初版代码

import pandas as pd
from typing import List, Optional


def calculate_ep_s(
    df: pd.DataFrame,
    kpi_col: str = "kpi",
    prev_kpi_col: str = "prev_kpi",
    dim_cols: Optional[List[str]] = None,
) -> pd.DataFrame:
    """计算各维度的EP(贡献度)和S(惊奇性)指标
    
    Args:
        df: 包含KPI指标和维度列的原始数据
        kpi_col: 当期KPI列名,默认为'kpi'
        prev_kpi_col: 前期KPI列名,默认为'prev_kpi'
        dim_cols: 需要分析的维度列列表,默认为['dim1', 'dim2']
    
    Returns:
        包含各维度值EP和S指标的DataFrame
    """
    # 初始化维度列参数
    dim_cols = dim_cols or ["dim1", "dim2"]
    
    # 计算KPI变化量
    df["change"] = df[kpi_col] - df[prev_kpi_col]
    total_change = df["change"].sum()
    
    ep_s_data = []
    for dim in dim_cols:
        for value in df[dim].unique():
            dim_df = df[df[dim] == value]
            dim_change = dim_df["change"].sum()
            dim_prev_kpi = dim_df[prev_kpi_col].mean()
            
            # 处理除零异常
            if dim_prev_kpi == 0:
                dim_prev_kpi = 1e-6
                
            # 计算EP指标(贡献度)
            ep = (dim_change / total_change * 100) if total_change != 0 else 0
            
            # 计算S指标(惊奇性)
            s = abs(dim_change / dim_prev_kpi)
            
            ep_s_data.append({
                "dimension": dim,
                "value": value,
                "EP": round(ep, 2),  # 保留两位小数
                "S": round(s, 4)    # 保留四位小数
            })
    
    return pd.DataFrame(ep_s_data)


if __name__ == "__main__":
    # 数据加载
    raw_df = pd.read_csv("test.csv")
    
    # 计算指标
    ep_s_df = calculate_ep_s(raw_df)
    
    # 根因分析
    print("\n== 维度惊奇性排序 ==")
    dim_s_rank = ep_s_df.groupby("dimension")["S"].sum().sort_values(ascending=False)
    print(dim_s_rank.to_markdown(tablefmt="github"), end="\n\n")
    
    # 选取TOP维度分析
    top_dim = dim_s_rank.index[0]
    print(f"== {top_dim} 维度详细分析 ==")
    
    top_dim_df = (
        ep_s_df[ep_s_df["dimension"] == top_dim]
        .sort_values(by=["EP", "S"], ascending=False)
        .reset_index(drop=True)
    )
    print(top_dim_df.to_markdown(index=False, tablefmt="github"))

关键改进和注意事项

  • 异常检测:上例中使用的是简单周同比。在实际应用中,应根据数据特性选择更合适的异常检测方法,例如时间序列模型(ARIMA、Prophet等)。
  • 处理0值:在计算S值时,要特别注意分母为0的情况,避免除零错误。通常的做法是添加一个极小值epsilon。
  • 多重根因:原始的Adtributor算法假设只有一个根因。实际情况可能存在多个根因。需要对算法进行扩展以支持多重根因的分析。例如,可以迭代地应用Adtributor算法,每次找到一个根因后将其影响移除,然后再次分析剩余的异常。
  • JS散度:一些资料中提到Adtributor使用JS散度来衡量差异性。在实际应用中,直接使用相对偏差或绝对偏差通常也足够有效,并且计算更简单。
  • 阈值的使用:一些资料中提到TEP和TEEP阈值。TEP阈值用于判断该维度下的解释力总和是否足够解释该异常。TEEP阈值用于筛选出高解释度的元素。可以根据实际需要添加。

JS散度

S散度(Jensen-Shannon Divergence)是一种衡量两个概率分布之间差异性的度量方式。它是基于KL散度(Kullback-Leibler Divergence)的改进版本,克服了KL散度的一些缺点。

KL散度的不足

  • 不对称性:KL(P||Q)不等于KL(Q||P),这意味着使用KL散度衡量P和Q的差异与衡量Q和P的差异结果可能不同。
  • 未定义性:当P(x)=0但Q(x)≠0时,KL散度会无穷大,导致无法计算。

JS散度的定义

JS散度定义为P和Q的平均分布与P和Q之间KL散度的平均值:JS(P||Q)=(1/2)*KL(P||M)+(1/2)*KL(Q||M)

其中M是P和Q的平均分布:M=(1/2)*(P+Q)

JS散度的优点

  • 对称性:JS(P||Q)=JS(Q||P),克服了KL散度不对称的缺点。
  • 有界性:JS散度的取值范围为[0,1],克服了KL散度可能无穷大的问题。当两个分布完全相同时,JS散度为0;当两个分布完全不同时,JS散度为1。
  • 更平滑:相比KL散度,JS散度更加平滑,这在一些机器学习任务中是有利的。

JS散度的应用

JS散度广泛应用于机器学习、深度学习等领域,例如:

  • 生成对抗网络(GAN):GAN的训练目标是最小化生成分布和真实分布之间的差异,JS散度可以作为一种衡量这种差异的度量方式。
  • 文本相似度:JS散度可以用于衡量两个文本的概率分布之间的差异,从而判断文本的相似度。
  • 聚类分析:JS散度可以用于衡量不同簇之间的差异,从而进行聚类分析。

与其他度量的比较:

  • KL散度:JS散度是KL散度的改进版本,克服了KL散度的不对称性和未定义性。
  • Wasserstein距离:Wasserstein距离(Earth Mover’s Distance)相比JS散度,在两个分布没有重叠或重叠很少的情况下,仍然能反映两个分布的远近,而JS散度可能为常量。但在计算复杂度上,Wasserstein距离通常比JS散度更高。

TEP阈值

TEP阈值(Threshold for Explained Proportion)主要应用于异常检测和根因分析领域,尤其是在需要解释KPI(Key Performance Indicator,关键绩效指标)异常波动的原因时。它衡量的是一组因素(或维度)能够解释KPI异常变动的程度。简单来说,TEP阈值设定了一个标准,只有当某些因素能够解释KPI异常变动的一定比例时,这些因素才会被认为是潜在的根因。

TEP阈值的意义和作用:

  • 筛选关键因素:在复杂的系统中,导致KPI异常变动的因素可能有很多。TEP阈值可以帮助我们筛选出那些能够对异常做出显著解释的关键因素,从而缩小排查范围,提高效率。
  • 量化解释能力:TEP阈值提供了一种量化指标,用于衡量一组因素对KPI异常变动的解释能力。通过设定不同的TEP阈值,我们可以调整分析的灵敏度,平衡误报和漏报的风险。
  • 指导根因分析:通过比较不同因素组合的解释比例与TEP阈值,我们可以判断哪些因素最有可能是导致KPI异常的根本原因,从而指导后续的根因分析工作。

TEP阈值的设定:TEP阈值通常设定在0到1之间。

  • 较高的TEP阈值(例如8或0.9):表示我们希望根因集合能够尽可能多地解释KPI的异常原因。这意味着只有当一组因素能够解释KPI异常变动的很大一部分时,这些因素才会被认为是潜在的根因。这种设定可以减少误报,但可能会漏掉一些解释力较弱但仍然重要的因素。
  • 较低的TEP阈值(例如5或0.6):表示我们允许根因集合解释KPI异常变动的一部分即可。这种设定可以减少漏报,但可能会增加误报,需要进一步的分析来确认真正的根因。

TEP阈值的具体设定需要根据实际的应用场景和数据特点进行调整。一般来说,如果对异常解释的要求较高,或者误报的代价较高,则应该选择较高的TEP阈值;反之,如果对漏报的容忍度较低,则可以选择较低的TEP阈值。

举例说明:

假设某个电商平台的日销售额(KPI)突然下降了10%。我们通过分析发现,以下因素可能与销售额下降有关:

  • 因素A:移动端访问量下降5%,导致销售额下降3%。
  • 因素B:促销活动力度减弱,导致销售额下降6%。
  • 因素C:竞争对手推出新的促销活动,导致销售额下降1%。

如果我们设定TEP阈值为0.7,那么只有因素A和因素B的组合(解释了9%的销售额下降,超过了70%的总下降)才会被认为是潜在的根因。而单独的因素A、因素B或因素C都无法达到TEP阈值。

TEEP阈值

TEEP (Threshold for Explained Element Proportion)阈值针对的是元素而言,用于筛选出在维度中具有高解释度的元素。它的作用是:在一个维度内,只保留那些对KPI异常波动解释力足够强的元素,排除那些影响较小的元素,从而缩小分析范围,提高效率。

TEEP阈值的具体作用:

  • 筛选高解释度元素:TEEP阈值设定了一个解释力度的下限。只有当一个元素对KPI异常变动的解释比例超过TEEP阈值时,该元素才会被认为是“可疑元素”,并被纳入该维度的根因集合中。
  • 降低噪声干扰:在实际应用中,很多因素可能对KPI产生微小的影响,但这些影响通常是随机的、不重要的“噪声”。TEEP阈值可以有效地过滤掉这些噪声,只关注那些真正对异常波动有显著影响的因素。
  • 提高分析效率:通过减少需要分析的元素数量,TEEP阈值可以显著提高根因分析的效率,尤其是在维度包含大量元素的情况下。

TEEP阈值的设定:

TEEP阈值通常设定为一个较小的值,例如1%、2%或5%。这个值的具体选择取决于以下因素:

  • KPI异常波动的幅度:如果KPI的异常波动幅度较大,可以适当提高TEEP阈值;反之,如果波动幅度较小,则应降低TEEP阈值,以避免遗漏重要的元素。
  • 数据噪声的程度:如果数据中噪声较多,可以适当提高TEEP阈值,以过滤掉更多的噪声;反之,如果数据质量较高,则可以降低TEEP阈值。
  • 分析的精细程度:如果需要进行更精细的分析,可以降低TEEP阈值,以保留更多的元素;反之,如果只需要找到主要的根因,则可以提高TEEP阈值。

举例说明:

假设我们分析某个电商平台的订单量下降问题,其中一个维度是“推广渠道”,包含以下元素:

  • 搜索引擎推广:导致订单量下降8%。
  • 社交媒体推广:导致订单量下降5%。
  • 邮件推广:导致订单量下降1%。
  • 合作伙伴推广:导致订单量下降5%。

如果我们设定TEEP阈值为2%,那么只有“搜索引擎推广”和“社交媒体推广”这两个元素会被认为是可疑元素,并被纳入“推广渠道”维度的根因集合中。“邮件推广”和“合作伙伴推广”由于解释力低于TEEP阈值,将被排除。

与TEP阈值的配合使用:

TEEP阈值用于在维度内部筛选元素,而TEP阈值用于判断维度整体是否重要。两者结合使用,可以有效地进行多维根因分析:

  • 首先,使用TEEP阈值在每个维度内筛选出高解释度的元素,形成每个维度的根因集合。
  • 然后,计算每个维度根因集合的解释力总和,并与TEP阈值进行比较,筛选出重要的维度。

通过这种方式,Adtributor算法可以有效地定位导致KPI异常波动的关键因素和关键维度,从而帮助用户快速找到根本原因。

迭代后的代码

import numpy as np
import pandas as pd
from scipy.stats import wasserstein_distance
from typing import List, Optional, Dict, Union


def js_divergence(p: np.ndarray, q: np.ndarray) -> float:
    """计算两个分布的Jensen-Shannon散度
    
    Args:
        p: 第一个概率分布
        q: 第二个概率分布
    
    Returns:
        标准化后的JS散度值
    """
    # 计算平均分布
    m = (p + q) / 2
    
    # 计算KL散度
    kl_p = np.sum(np.where(p != 0, p * np.log(p / m), 0))
    kl_q = np.sum(np.where(q != 0, q * np.log(q / m), 0))
    
    return (kl_p + kl_q) / 2


def calculate_ep_s(
    df: pd.DataFrame,
    kpi_col: str = "kpi",
    prev_kpi_col: str = "prev_kpi",
    dim_cols: Optional[List[str]] = None,
    use_js: bool = True,
) -> pd.DataFrame:
    """计算各维度指标的EP(贡献度)和S(分布差异)
    
    Args:
        df: 包含KPI指标的原始数据
        kpi_col: 当期KPI列名,默认为'kpi'
        prev_kpi_col: 前期KPI列名,默认为'prev_kpi'
        dim_cols: 分析维度列列表,默认为['dim1', 'dim2']
        use_js: 是否使用JS散度计算S值,默认为True
    
    Returns:
        包含各维度EP和S指标的DataFrame
    """
    # 初始化维度列
    dim_cols = dim_cols or ["dim1", "dim2"]
    
    # 计算KPI变化量
    df["change"] = df[kpi_col] - df[prev_kpi_col]
    total_change = df["change"].sum()
    
    results = []
    for dimension in dim_cols:
        for dim_value in df[dimension].unique():
            # 获取维度子集
            subset = df[df[dimension] == dim_value]
            dim_change = subset["change"].sum()
            
            # 计算EP指标
            ep = (dim_change / total_change * 100) if total_change != 0 else 0
            
            # 计算S指标
            if use_js:
                # 归一化概率分布
                p = subset[kpi_col].values
                q = df[kpi_col].values
                
                # 处理零概率分布
                p_norm = p / p.sum() if p.sum() > 0 else np.ones_like(p)/len(p)
                q_norm = q / q.sum() if q.sum() > 0 else np.ones_like(q)/len(q)
                
                s = js_divergence(p_norm, q_norm)
            else:
                # 原始S值计算
                prev_kpi_mean = subset[prev_kpi_col].mean() or 1e-6  # 处理零值
                s = abs(dim_change / prev_kpi_mean)
            
            results.append({
                "dimension": dimension,
                "value": dim_value,
                "EP": round(ep, 2),
                "S": round(s, 4)
            })
    
    return pd.DataFrame(results)


def root_cause_analysis(
    ep_s_df: pd.DataFrame,
    tep_threshold: float = 50.0,
    teep_threshold: float = 5.0
) -> List[str]:
    """执行根因分析并返回重要维度
    
    Args:
        ep_s_df: 包含EP和S指标的DataFrame
        tep_threshold: 维度总EP阈值,默认50%
        teep_threshold: 元素EP阈值,默认5%
    
    Returns:
        超过阈值的重要维度列表
    """
    # 按维度计算总S值排序
    dim_ranking = (
        ep_s_df.groupby("dimension")["S"]
        .sum()
        .sort_values(ascending=False)
    )
    print("\n=== 维度惊奇性排序 ===")
    print(dim_ranking.to_markdown(tablefmt="github"))
    
    significant_dims = []
    for dim in dim_ranking.index:
        dim_data = ep_s_df[ep_s_df["dimension"] == dim]
        total_ep = dim_data["EP"].abs().sum()
        
        # TEP阈值判断
        if total_ep >= tep_threshold:
            significant_dims.append(dim)
            print(f"\n维度 {dim} 总EP ({total_ep:.2f}%) 超过阈值 {tep_threshold}%")
            
            # 筛选显著元素
            significant_elements = dim_data[
                dim_data["EP"].abs() >= teep_threshold
            ].sort_values(by=["EP", "S"], ascending=False)
            
            if not significant_elements.empty:
                print(f"\n{dim} 维度显著元素 (EP ≥ {teep_threshold}%):")
                print(significant_elements.to_markdown(index=False, tablefmt="github"))
            else:
                print(f"{dim} 维度无元素超过TEEP阈值")
    
    if not significant_dims:
        print("\n警告:没有维度超过TEP阈值")
    
    return significant_dims


if __name__ == "__main__":
    # 数据准备
    data = pd.read_csv("test.csv")
    
    # 指标计算(使用JS散度)
    analysis_df = calculate_ep_s(data, use_js=True)
    
    # 执行根因分析
    print("\n=== 根因分析开始 ===")
    important_dims = root_cause_analysis(
        analysis_df,
        tep_threshold=10.0,
        teep_threshold=2.0
    )
    print("\n=== 分析结果 ===")
    print("重要维度:", important_dims)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注