检测数据异常值的五种统计技术

文章来源声明: 原文作者:databook; 来源站点:掘金; 原文链接:https://juejin.cn/post/7689305673976823835; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

从原理、代码到业务案例一次讲清,适合数据分析师和算法工程师按场景选型,快速筛异常并降低模型风险。

处理数据的时候,最让人头大的往往不是复杂的模型调参,而是那些藏在角落里的异常值。

你是不是也遇到过这种糟心事:明明业务逻辑看着没毛病,一跑统计指标就飘红;或者模型效果突然断崖式下跌,排查半天发现是几个极端值在捣乱。

今天我们来聊聊最常用的 5 种异常值检测方法:Z-Score、IQR、LOF、Isolation Forest、马氏距离。

通过这篇文章,你将看到:

  • 什么数据形态该用哪种异常值检测方法。
  • 怎么用 Python 快速进行异常值检测并跑出结果。
  • 怎么把代码套到自己的业务数据上。

下面我们一一来看。

  1. Z-Score:用标准差倍数判断异常值

Z-Score 是最简单的一种,它算每个点和均值的距离,相当于几个标准差。一般 |Z| > 3 就算异常。

适合单变量、分布接近正态的数据。

下面拿北京地铁某站早高峰每分钟进站人数举例。

正常情况每分钟一百多人,偶尔会有突发大客流或临时限流。

下面造 200 个正常值,围绕 120 波动,再塞 280、20、300 三个极端值。

<span>import</span> numpy <span>as</span> np

np.random.seed(<span>42</span>)
normal = np.random.normal(<span>120</span>, <span>15</span>, <span>200</span>)
data = np.concatenate([normal, [<span>280</span>, <span>20</span>, <span>300</span>]])

mean = np.mean(data)
std = np.std(data)
z_scores = (data - mean) / std
outlier_indices = np.where(np.<span>abs</span>(z_scores) > <span>3</span>)[<span>0</span>]

<span>print</span>(<span>"Z-Score 异常值:"</span>)
<span>for</span> idx <span>in</span> outlier_indices:
    <span>print</span>(<span>f"索引 <span>{idx}</span>, 值 <span>{data[idx]:<span>.2</span>f}</span>, Z=<span>{z_scores[idx]:<span>.2</span>f}</span>"</span>)

Z-Score 异常值:
索引 200, 值 280.00, Z=6.95
索引 201, 值 20.00, Z=-4.38
索引 202, 值 300.00, Z=7.82

跑完的输出里,280、20、300 的 Z 值绝对值都超过 3,会被标出来,正常点基本不会误报。

这种单变量、近似正态的场景,Z-Score 简单直接,够用。

  1. IQR:用四分位距判断异常值

IQR 不依赖均值和标准差,它看中间 50% 的数据范围。

超出 Q1 - 1.5 * IQR 或 Q3 + 1.5 * IQR 的点算异常。

适合偏态分布,或者有极端值的数据。

拿成都某小区住户月度电费举例。

大多数住户几十到两三百,少数开民宿、挖矿或者空调常开的,电费特别高。

电费本身是偏态的,用 IQR 比 Z-Score 稳。

<span>import</span> numpy <span>as</span> np

np.random.seed(<span>1</span>)
normal = np.random.gamma(shape=<span>4</span>, scale=<span>30</span>, size=<span>300</span>)
data = np.concatenate([normal, [<span>2000</span>, <span>5000</span>, <span>8000</span>]])

q1 = np.percentile(data, <span>25</span>)
q3 = np.percentile(data, <span>75</span>)
iqr = q3 - q1
lower_bound = q1 - <span>1.5</span> * iqr
upper_bound = q3 + <span>1.5</span> * iqr

outliers = data[(data < lower_bound) | (data > upper_bound)]

<span>print</span>(<span>f"Q1=<span>{q1:<span>.2</span>f}</span>, Q3=<span>{q3:<span>.2</span>f}</span>, IQR=<span>{iqr:<span>.2</span>f}</span>"</span>)
<span>print</span>(<span>f"下界=<span>{lower_bound:<span>.2</span>f}</span>, 上界=<span>{upper_bound:<span>.2</span>f}</span>"</span>)
<span>print</span>(<span>"IQR 异常值:"</span>, outliers)

Q1=77.89, Q3=161.82, IQR=83.93
下界=-48.01, 上界=287.72
IQR 异常值: [ 289.37700599  289.53075874  289.93834029  328.543792   2000.
 5000.         8000.        ]

跑完会看到上界通常在两三百左右,2000、5000、8000 这些高额电费会被抓出来,少数正常偏高的电费也可能被带上。

整体上 IQR 对偏态数据比较抗干扰,适合先做一轮粗筛。

  1. LOF:用局部密度判断异常值

LOF 看的是局部密度。它比较一个点和它邻居的密度,如果明显比周围稀疏,就认为异常。

适合多变量、局部密度不一样的数据。

拿杭州共享单车停放点早高峰借还车量举例。

正常站点借车和还车量大致都在 30 左右,少数站点借车量极高、还车量极低,或者反过来。

这种异常不看单列数值,而是看组合和周围像不像。

<span>import</span> numpy <span>as</span> np
<span>from</span> sklearn.neighbors <span>import</span> LocalOutlierFactor

np.random.seed(<span>42</span>)
normal = np.random.normal([<span>30</span>, <span>30</span>], [<span>5</span>, <span>5</span>], size=(<span>200</span>, <span>2</span>))
outliers = np.array([[<span>5</span>, <span>80</span>], [<span>85</span>, <span>10</span>], [<span>90</span>, <span>90</span>], [<span>10</span>, <span>10</span>]])
data = np.vstack([normal, outliers])

lof = LocalOutlierFactor(n_neighbors=<span>20</span>, contamination=<span>0.02</span>)
labels = lof.fit_predict(data)
outlier_indices = np.where(labels == -<span>1</span>)[<span>0</span>]

<span>print</span>(<span>"LOF 异常点:"</span>)
<span>for</span> idx <span>in</span> outlier_indices:
    <span>print</span>(<span>f"索引 <span>{idx}</span>, 借车量=<span>{data[idx][<span>0</span>]:<span>.1</span>f}</span>, 还车量=<span>{data[idx][<span>1</span>]:<span>.1</span>f}</span>"</span>)

LOF 异常点:
索引 104, 借车量=32.6, 还车量=49.3
索引 200, 借车量=5.0, 还车量=80.0
索引 201, 借车量=85.0, 还车量=10.0
索引 202, 借车量=90.0, 还车量=90.0
索引 203, 借车量=10.0, 还车量=10.0

跑完输出里,像 (5,80)、(85,10)、(90,90)、(10,10) 这种和正常站点密度明显不同的点会被标出来。

LOF 的好处是不要求全局统一分布,能找出局部行为异常的站点。

  1. Isolation Forest:用随机隔离判断异常值

Isolation Forest 的思路是随机切分数据,异常点因为稀疏,通常几步就被单独隔离出来。适合数据量大、维度高的场景。

拿 618 电商订单举例。正常订单金额几十到几百,商品件数 1 到 5 件。异常订单可能是刷单、大额采购或恶意测试,金额和件数组合很离谱。

<span>import</span> numpy <span>as</span> np
<span>from</span> sklearn.ensemble <span>import</span> IsolationForest

np.random.seed(<span>7</span>)
n = <span>1000</span>
amount = np.random.normal(<span>200</span>, <span>80</span>, n).clip(<span>20</span>, <span>800</span>)
items = np.random.poisson(<span>2</span>, n) + <span>1</span>
normal = np.column_stack([amount, items])

outliers = np.array([
    [<span>9999</span>, <span>100</span>],
    [<span>0.01</span>, <span>500</span>],
    [<span>5000</span>, <span>1</span>],
    [<span>3000</span>, <span>200</span>]
])
data = np.vstack([normal, outliers])

iso = IsolationForest(contamination=<span>0.01</span>, random_state=<span>42</span>)
labels = iso.fit_predict(data)
outlier_indices = np.where(labels == -<span>1</span>)[<span>0</span>]

<span>print</span>(<span>"Isolation Forest 异常订单:"</span>)
<span>for</span> idx <span>in</span> outlier_indices:
    <span>print</span>(<span>f"索引 <span>{idx}</span>, 金额=<span>{data[idx][<span>0</span>]:<span>.2</span>f}</span>, 件数=<span>{data[idx][<span>1</span>]:<span>.0</span>f}</span>"</span>)

Isolation Forest 异常订单:
索引 47, 金额=379.81, 件数=1
索引 186, 金额=90.41, 件数=7
索引 267, 金额=224.73, 件数=9
索引 658, 金额=356.52, 件数=6
索引 660, 金额=109.25, 件数=8
索引 662, 金额=329.58, 件数=9
索引 888, 金额=60.75, 件数=9
索引 1000, 金额=9999.00, 件数=100
索引 1001, 金额=0.01, 件数=500
索引 1002, 金额=5000.00, 件数=1
索引 1003, 金额=3000.00, 件数=200

跑完输出里,9999 元 100 件、0.01 元 500 件、5000 元 1 件、3000 元 200 件这些订单会被标出来。

因为 contamination 设成 0.01,可能还会误伤少量正常订单。

总体看,Isolation Forest 跑得快,适合先在大数据里筛可疑样本。

  1. 马氏距离:用协方差结构判断异常值

马氏距离考虑变量之间的协方差。单看某一项可能正常,组合起来很怪,它就能抓出来。

适合多变量且变量之间有相关性的数据。

拿某高中体测数据举例。身高、体重、肺活量三个变量有关联。

正常情况身高越高,体重和肺活量通常也会高一些。

下面造 300 条正常记录,再塞三条组合很怪的数据。

<span>import</span> numpy <span>as</span> np
<span>from</span> scipy.spatial.distance <span>import</span> mahalanobis

np.random.seed(<span>42</span>)
n = <span>300</span>
height = np.random.normal(<span>170</span>, <span>6</span>, n)
weight = <span>60</span> + <span>0.7</span> * (height - <span>170</span>) + np.random.normal(<span>0</span>, <span>5</span>, n)
lung = <span>3500</span> + <span>30</span> * (height - <span>170</span>) + np.random.normal(<span>0</span>, <span>300</span>, n)
normal = np.column_stack([height, weight, lung])

outliers = np.array([
    [<span>170</span>, <span>120</span>, <span>2000</span>],
    [<span>150</span>, <span>80</span>, <span>5000</span>],
    [<span>190</span>, <span>45</span>, <span>3000</span>]
])
data = np.vstack([normal, outliers])

mean = np.mean(data, axis=<span>0</span>)
cov = np.cov(data, rowvar=<span>False</span>)
inv_cov = np.linalg.inv(cov)

distances = np.array([mahalanobis(x, mean, inv_cov) <span>for</span> x <span>in</span> data])
threshold = np.percentile(distances, <span>97.5</span>)
outlier_indices = np.where(distances > threshold)[<span>0</span>]

<span>print</span>(<span>"马氏距离异常体测记录:"</span>)
<span>for</span> idx <span>in</span> outlier_indices:
    <span>print</span>(<span>f"索引 <span>{idx}</span>, 身高=<span>{data[idx][<span>0</span>]:<span>.1</span>f}</span>, 体重=<span>{data[idx][<span>1</span>]:<span>.1</span>f}</span>, 肺活量=<span>{data[idx][<span>2</span>]:<span>.0</span>f}</span>, 距离=<span>{distances[idx]:<span>.2</span>f}</span>"</span>)

马氏距离异常体测记录:
索引 <span>46</span>, 身高=<span>167.2</span>, 体重=<span>50.8</span>, 肺活量=<span>2608</span>, 距离=<span>2.87</span>
索引 <span>74</span>, 身高=<span>154.3</span>, 体重=<span>59.8</span>, 肺活量=<span>2606</span>, 距离=<span>3.33</span>
索引 <span>179</span>, 身高=<span>186.3</span>, 体重=<span>77.0</span>, 肺活量=<span>4176</span>, 距离=<span>3.08</span>
索引 <span>209</span>, 身高=<span>193.1</span>, 体重=<span>72.0</span>, 肺活量=<span>3925</span>, 距离=<span>3.82</span>
索引 <span>262</span>, 身高=<span>150.6</span>, 体重=<span>50.7</span>, 肺活量=<span>3381</span>, 距离=<span>3.31</span>
索引 <span>300</span>, 身高=<span>170.0</span>, 体重=<span>120.0</span>, 肺活量=<span>2000</span>, 距离=<span>10.35</span>
索引 <span>301</span>, 身高=<span>150.0</span>, 体重=<span>80.0</span>, 肺活量=<span>5000</span>, 距离=<span>8.29</span>
索引 <span>302</span>, 身高=<span>190.0</span>, 体重=<span>45.0</span>, 肺活量=<span>3000</span>, 距离=<span>6.06</span>

跑完输出里,身高 170、体重 120、肺活量 2000;

身高 150、体重 80、肺活量 5000;

身高 190、体重 45、肺活量 3000 这种组合会被标出来。

因为阈值取 97.5 百分位,可能还会带上少量边缘正常记录。

马氏距离适合处理变量有关联的多维异常检测,但协方差估计不准时结果会受影响。

总结

怎么选这 5 种方法?

  • Z-Score:单变量,近似正态分布,最省事。
  • IQR:单变量,偏态分布,或者有极端值,比较稳。
  • LOF:多变量,数据有局部密度差异,适合找“周围不一样”的点。
  • Isolation Forest:数据量大、维度高,想快速筛异常,优先用。
  • 马氏距离:多变量之间有明显相关性,需要把相关性考虑进去。

注意:不管用哪种方法,最后都要结合业务进一步来判断是否异常值,不能只看算法结果。