数据, 术→技巧, 机器学习, 法→原理

时间序列异常检测算法综述

钱魏Way · · 9,303 次浏览

[LATEXPAGE]

异常的分类

时间序列的异常检测问题通常表示为相对于某些标准信号或常见信号的离群点。虽然有很多的异常类型,但是我们只关注业务角度中最重要的类型,比如意外的峰值、下降、趋势变化以及等级转换(level shifts)。

常见的异常有如下几种:

  • 革新性异常:innovational outlier (IO),造成离群点的干扰不仅作用于$X_T$,而且影响T时刻以后序列的所有观察值。
  • 附加性异常:additive outlier (AO),造成这种离群点的干扰,只影响该干扰发生的那一个时刻T上的序列值,而不影响该时刻以后的序列值。
  • 水平移位异常:level shift (LS),造成这种离群点的干扰是在某一时刻T,系统的结构发生了变化,并持续影响T时刻以后的所有行为,在数列上往往表现出T时刻前后的序列均值发生水平位移。
  • 暂时变更异常temporary change (TC):造成这种离群点的干扰是在T时刻干扰发生时具有一定初始效应,以后随时间根据衰减因子的大小呈指数衰减。

上面的解释可能不太容易理解,我们结合图片来看一下:

通常,异常检测算法应该将每个时间点标记为异常/非异常,或者预测某个点的信号,并衡量这个点的真实值与预测值的差值是否足够大,从而将其视为异常。使用后面的方法,你将能够得到一个可视化的置信区间,这有助于理解为什么会出现异常并进行验证。

常见异常检测方法

从分类看,当前发展阶段的时序异常检测算法和模型可以分为一下几类:

  • 统计模型:优点是复杂度低,计算速度快,泛化能力强悍。因为没有训练过程,即使没有前期的数据积累,也可以快速的投入生产使用。缺点是准确率一般。但是这个其实是看场景的,并且也有简单的方法来提高业务层面的准确率。这个后面会提到。
  • 机器学习模型:鲁棒性较好,准确率较高。需要训练模型,泛化能力一般。
  • 深度学习模型:普遍需要喂大量的数据,计算复杂度高。整体看,准确性高,尤其是近段时间,强化学习的引入,进一步巩固其准确性方面的领先优势。

3-Sigma

3-Sigma原则又称为拉依达准则,该准则定义如下:假设一组检测数据只含有随机误差,对原始数据进行计算处理得到标准差,然后按一定的概率确定一个区间,认为误差超过这个区间的就属于异常值。

使用3-Sigma的前提是数据服从正态分布,满足这个条件之后,在3-Sigma范围$(\mu – 3\sigma ,\mu + 3\sigma)$内99.73%的为正常数据,其中$\sigma$代表标准差,$\mu$代表均值,$x=\mu$为图形的对称轴。下面是3-Sigma的Python实现:

import numpy as np


def three_sigma(df_col):
    '''
    df_col:DataFrame数据的某一列
    '''
    rule = (df_col.mean() - 3 * df_col.std() > df_col) | (df_col.mean() + 3 * df_col.std() < df_col)
    index = np.arange(df_col.shape[0])[rule]
    out_range = df_col.iloc[index]
    return out_range

对于异常值检测出来的结果,有多种处理方式,如果是时间序列中的值,那么我们可以认为这个时刻的操作属于异常的;如果是将异常值检测用于数据预处理阶段,处理方法有以下四种:

  • 删除带有异常值的数据;
  • 将异常值视为缺失值,交给缺失值处理方法来处理;
  • 用平均值进行修正;
  • 当然我们也可以选择不处理。

Grubbs测试

Grubbs’Test为一种假设检验的方法,常被用来检验服从正太分布的单变量数据集(univariate data set)Y 中的单个异常值。若有异常值,则其必为数据集中的最大值或最小值。原假设与备择假设如下:

  • $H_0$: 数据集中没有异常值
  • $H_1$: 数据集中有一个异常值

Grubbs’ Test检验假设的所用到的检验统计量(test statistic)为:

$$G = \frac{\max |Y_i – \overline{Y}|}{s}$$

其中,$\overline{Y}$为均值,s为标准差。原假设$H_0$被拒绝,当检验统计量满足以下条件:

$$G > \frac{(N-1)}{\sqrt{N}}\sqrt{\frac{ (t_{\alpha/(2N), N-2})^2}{N-2 + (t_{\alpha/(2N), N-2})^2}}$$

其中,$N$为数据集的样本数,$t_{\alpha/(2N), N-2}$为显著度(significance level)等于$\alpha/(2N)$、自由度(degrees of freedom)等于$N-2$的t分布临界值。实际上,Grubbs’ Test可理解为检验最大值、最小值偏离均值的程度是否为异常。

使用Grubbs测试需要总体是正态分布的。算法流程:

  1. 样本从小到大排序
  2. 求样本的mean和dev
  3. 计算min/max与mean的差距,更大的那个为可疑值
  4. 求可疑值的z-score (standard score),如果大于Grubbs临界值,那么就是outlier

Grubbs临界值可以查表得到,它由两个值决定:检出水平$\alpha$(越严格越小),样本数量n,排除outlier,对剩余序列循环做 1-4 步骤。由于这里需要的是异常判定,只需要判断tail_avg是否outlier即可。

这里找到了一个Python包:https://pypi.org/project/outlier_utils/

使用示例:

from outliers import smirnov_grubbs as grubbs

print(grubbs.test([8, 9, 10, 1, 9], alpha=0.05))
print(grubbs.min_test_outliers([8, 9, 10, 1, 9], alpha=0.05))
print(grubbs.max_test_outliers([8, 9, 10, 1, 9], alpha=0.05))
print(grubbs.max_test_indices([8, 9, 10, 50, 9], alpha=0.05))

ESD(generalized ESD test)

在现实数据集中,异常值往往是多个而非单个。为了将Grubbs’ Test扩展到k个异常值检测,则需要在数据集中逐步删除与均值偏离最大的值(为最大值或最小值),同步更新对应的t分布临界值,检验原假设是否成立。基于此,Rosner提出了Grubbs’ Test的泛化版ESD(Extreme Studentized Deviate test)。算法流程如下:

  1. 计算与均值偏离最远的残差,注意计算均值时的数据序列应是删除上一轮最大残差样本数据后。$R_j = \frac{\max_i |Y_i – \overline{Y’}|}{s}, 1 \leq j \leq k$
  2. 计算临界值(critical value).$\lambda_j = \frac{(n-j) * t_{p,n-j-1}}{\sqrt{(n-j-1+t_{p,n-j-1}^2)(n-j+1)}}, 1 \leq j \leq k$
  3. 检验原假设,比较检验统计量与临界值;若$R_i > \lambda_j$,则原假设$H_0$不成立,该样本点为异常点
  4. 重复以上步骤k次至算法结束。

Python包:https://www.hs.uni-hamburg.de/DE/Ins/Per/Czesla/PyA/PyA/index.html

使用方法:

import numpy as np
import matplotlib.pylab as plt
from PyAstronomy import pyasl

# Convert data given at:
# http://www.itl.nist.gov/div898/handbook/eda/section3/eda35h3.htm
# to array.
x = np.array([float(x) for x in "-0.25 0.68 0.94 1.15 1.20 1.26 1.26 1.34 1.38 1.43 1.49 1.49 \
          1.55 1.56 1.58 1.65 1.69 1.70 1.76 1.77 1.81 1.91 1.94 1.96 \
          1.99 2.06 2.09 2.10 2.14 2.15 2.23 2.24 2.26 2.35 2.37 2.40 \
          2.47 2.54 2.62 2.64 2.90 2.92 2.92 2.93 3.21 3.26 3.30 3.59 \
          3.68 4.30 4.64 5.34 5.42 6.01".split()])

# Apply the generalized ESD
r = pyasl.generalizedESD(x, 10, 0.05, fullOutput=True)

print("Number of outliers: ", r[0])
print("Indices of outliers: ", r[1])
print("        R      Lambda")
for i in range(len(r[2])):
    print("%2d  %8.5f  %8.5f" % ((i + 1), r[2][i], r[3][i]))

# Plot the "data"
plt.plot(x, 'b.')
# and mark the outliers.
for i in range(r[0]):
    plt.plot(r[1][i], x[r[1][i]], 'rp')
plt.show()

S-ESDS-H-ESD

鉴于时间序列数据具有周期性(seasonal)、趋势性(trend),异常检测时不能作为孤立的样本点处理;故而Twitter的工程师提出了S- ESD (Seasonal ESD)与S-H-ESD (Seasonal Hybrid ESD)算法,将ESD扩展到时间序列数据。

STL分解

STL (Seasonal-Trend decomposition procedure based on Loess) 为时序分解中一种常见的算法,基于LOESS将某时刻的数据$Y_v$分解为趋势分量(trend component)、季节性分量(seasonal component)和残差(remainder component):

$$Y_v = T _v + S_v + R_v \quad v= 1, \cdots, N$$

由上到下依次为:原始时间序列和使用 STL 分解得到的季节变化部分、趋势变化部分以及残差部分。

STL分为内循环(inner loop)与外循环(outer loop),其中内循环主要做了趋势拟合与周期分量的计算。假定$T_v^{(k)}$、Sv(k)为内循环中第k-1次pass结束时的趋势分量、周期分量,初始时T(k)v=0;并有以下参数:

  • n(i)内层循环数
  • n(o)外层循环数
  • n(p)为一个周期的样本数
  • n(s)为Step 2中LOESS平滑参数
  • n(l)为Step 3中LOESS平滑参数
  • n(t)为Step 6中LOESS平滑参数

每个周期相同位置的样本点组成一个子序列(subseries),容易知道这样的子序列共有共有n(p)个,我们称其为cycle-subseries。内循环主要分为以下6个步骤:

  • Step 1: 去趋势(Detrending),减去上一轮结果的趋势分量,$Y_v – T_v^{(k)}$
  • Step 2: 周期子序列平滑(Cycle-subseries smoothing),用$\text{LOESS}(q=n_{n(s)}, d=1)$对每个子序列做回归,并向前向后各延展一个周期;平滑结果组成temporary seasonal series,记为$C_v^{(k+1)}, v = -n_{(p)} + 1, \cdots, -N + n_{(p)} $
  • Step 3: 周期子序列的低通量过滤(Low-Pass Filtering),对上一个步骤的结果序列$C_v^{(k+1)}$依次做长度为n(p)、n(p)、3的滑动平均(moving average),然后做$\text{LOESS}(q=n_{n(l)}, d=1)$回归,得到结果序列$L_v^{(k+1)}, v = 1, \cdots, N$;相当于提取周期子序列的低通量
  • Step 4: 去除平滑周期子序列趋势(Detrending of Smoothed Cycle-subseries),$S_v^{(k+1)} = C_v^{(k+1)} – L_v^{(k+1)}$
  • Step 5: 去周期(Deseasonalizing),减去周期分量,$Y_v – S_v^{(k+1)}$
  • Step 6: 趋势平滑(Trend Smoothing),对于去除周期之后的序列做$\text{LOESS}(q=n_{n(t)}, d=1)$回归,得到趋势分量$T_v^{(k+1)}$。

外层循环主要用于调节robustness weight。如果数据序列中有outlier,则余项会较大。定义:

$$h = 6 * median(|R_v|)$$

对于位置为v的数据点,其robustness weight为:

$$\rho_v = B(|R_v|/h)$$

其中B函数为bisquare函数:

$$B(u) = \left\{\begin{matrix}(1-u^2)^2 & 0 \le u < 1 \\ 0 & u \ge 1 \end{matrix}\right.$$

然后每一次迭代的内循环中,在Step 2与Step 6中做LOESS回归时,邻域权重(neighborhood weight)需要乘以$\rho_v$,以减少outlier对回归的影响。STL的具体流程如下:

outer loop:
    计算robustness weight;
    inner loop:
        Step 1 去趋势;
        Step 2 周期子序列平滑;
        Step 3 周期子序列的低通量过滤;
        Step 4 去除平滑周期子序列趋势;
        Step 5 去周期;
        Step 6 趋势平滑;

为了使得算法具有足够的robustness,所以设计了内循环与外循环。特别地,当n(i)足够大时,内循环结束时趋势分量与周期分量已收敛;若时序数据中没有明显的outlier,可以将n(o)设为0。

Python的statsmodels实现了一个简单版的时序分解,通过加权滑动平均提取趋势分量,然后对cycle-subseries每个时间点数据求平均组成周期分量:

使用示例:

import numpy as np
import pandas as pd
from statsmodels.tsa.seasonal import seasonal_decompose
import matplotlib.pyplot as plt

# Generate some data
np.random.seed(0)
n = 1500
dates = np.array('2019-01-01', dtype=np.datetime64) + np.arange(n)
data = 12 * np.sin(2 * np.pi * np.arange(n) / 365) + np.random.normal(12, 2, 1500)
df = pd.DataFrame({'data': data}, index=dates)

# Reproduce the example in OP
seasonal_decompose(df, model='additive', period=1).plot()
plt.show()

参考链接:statsmodels.tsa.seasonal.seasonal_decompose

S-ESD

STL将时间序列数据分解为趋势分量、周期分量和余项分量。想当然的解法——将ESD运用于STL分解后的余项分量中,即可得到时间序列上的异常点。但是,我们会发现在余项分量中存在着部分假异常点(spurious anomalies)。如下图所示:

在红色矩形方框中,向下突起点被误报为异常点。为了解决这种假阳性降低准确率的问题,S-ESD算法用中位数(median)替换掉趋势分量;余项计算公式如下:

$$R_X = X – S_X- \tilde{X}$$

其中,$X$为原时间序列数据,$S_X$为STL分解后的周期分量,$\tilde{X}$为$X$的中位数。

Python包:https://github.com/nachonavarro/seasonal-esd-anomaly-detection

使用示例:

import numpy as np
import sesd
ts = np.random.random(100)
# Introduce artificial anomalies
ts[14] = 9
ts[83] = 10
outliers_indices = sesd.seasonal_esd(ts, periodicity=20, hybrid=True, max_anomalies=2)
for idx in outliers_indices:
    print(f'Anomaly index: {idx}, anomaly value: {ts[idx]}')

S-H-ESD

由于个别异常值会极大地拉伸均值和方差,从而导致S-ESD未能很好地捕获到部分异常点,召回率偏低。为了解决这个问题,S-H-ESD采用了更具鲁棒性的中位数与绝对中位差(Median Absolute Deviation, MAD)替换公式中的均值与标准差。MAD的计算公式如下:

$$\text{MAD} = median(|X_i – median(X)|)$$

Python包:https://github.com/zrnsm/pyculiarity

使用示例:

from pyculiarity import detect_ts
import matplotlib.pyplot as plt
import pandas as pd

# first run the models
twitter_example_data = pd.read_csv('./raw_data.csv', usecols=['timestamp', 'count'])
results = detect_ts(twitter_example_data, max_anoms=0.05, alpha=0.001, direction='both', only_last=None)

# format the twitter data nicely
twitter_example_data['timestamp'] = pd.to_datetime(twitter_example_data['timestamp'])
twitter_example_data.set_index('timestamp', drop=True)

# make a nice plot
f, ax = plt.subplots(2, 1, sharex=True)
ax[0].plot(twitter_example_data['timestamp'], twitter_example_data['value'], 'b')
ax[0].plot(results['anoms'].index, results['anoms']['anoms'], 'ro')
ax[0].set_title('Detected Anomalies')
ax[1].set_xlabel('Time Stamp')
ax[0].set_ylabel('Count')
ax[1].plot(results['anoms'].index, results['anoms']['anoms'], 'b')
ax[1].set_ylabel('Anomaly Magnitude')
plt.show()

R语言版:https://github.com/twitter/AnomalyDetection

PyCuliarity的使用详解

Twitter开源的异常检测工具AnomalyDetection是基于R语言的,PyCuliarity是基于AnomalyDetection开的的Python版本。由于AnomalyDetection和PyCuliarity都没有相关的文档,这里将代码中抠出来的注释拿出来做下学习。

PyCuliarity有两个顶级函数,一个用于timeseries数据,一个用于向量数据处理,detect_ts和 detect_vec:

  • detect_ts:输入的DataFrame需要两列数据,其中一列为时间,另一列为该时间点对应的值
  • detect_vec:可以不包含是时间列,时间索引按照DataFrame长度自动生成。

两者的使用方法基本一致,这里主要介绍detect_ts的使用,源码注释如下:

def detect_ts(df, max_anoms=0.10, direction='pos',
              alpha=0.05, only_last=None, threshold=None,
              e_value=False, longterm=False,
              piecewise_median_period_weeks=2, plot=False,
              y_log=False, xlabel = '', ylabel = 'count',
              title=None, verbose=False):
    """
    Anomaly Detection Using Seasonal Hybrid ESD Test
    A technique for detecting anomalies in seasonal univariate time series where the input is a
    series of <timestamp, value> pairs.

    Args:

    x: Time series as a two column data frame where the first column consists of the
    timestamps and the second column consists of the observations.

    max_anoms: Maximum number of anomalies that S-H-ESD will detect as a percentage of the
    data.

    direction: Directionality of the anomalies to be detected. Options are: ('pos' | 'neg' | 'both').

    alpha: The level of statistical significance with which to accept or reject anomalies.

    only_last: Find and report anomalies only within the last day or hr in the time series. Options: (None | 'day' | 'hr')

    threshold: Only report positive going anoms above the threshold specified. Options are: (None | 'med_max' | 'p95' | 'p99')

    e_value: Add an additional column to the anoms output containing the expected value.

    longterm: Increase anom detection efficacy for time series that are greater than a month.

    See Details below.
    piecewise_median_period_weeks: The piecewise median time window as described in Vallis, Hochenbaum, and Kejariwal (2014). Defaults to 2.

    plot: (Currently unsupported) A flag indicating if a plot with both the time series and the estimated anoms,
    indicated by circles, should also be returned.

    y_log: Apply log scaling to the y-axis. This helps with viewing plots that have extremely
    large positive anomalies relative to the rest of the data.

    xlabel: X-axis label to be added to the output plot.
    ylabel: Y-axis label to be added to the output plot.

    Details


    'longterm' This option should be set when the input time series is longer than a month.
    The option enables the approach described in Vallis, Hochenbaum, and Kejariwal (2014).
    'threshold' Filter all negative anomalies and those anomalies whose magnitude is smaller
    than one of the specified thresholds which include: the median
    of the daily max values (med_max), the 95th percentile of the daily max values (p95), and the
    99th percentile of the daily max values (p99).
    'title' Title for the output plot.
    'verbose' Enable debug messages

    The returned value is a dictionary with the following components:
      anoms: Data frame containing timestamps, values, and optionally expected values.
      plot: A graphical object if plotting was requested by the user. The plot contains
      the estimated anomalies annotated on the input time series
    """

参数详解:

  • df:包含时间和值的DataFrame
  • max_anoms=0.10:发现异常数据的量(占总体的百分之多少)
  • direction=’pos’:’pos’是发现数据突增点,’neg’是发现数据突降点,’both’是包含突增与突降
  • alpha=0.05:接受或拒绝显著性水平,即p-value
  • only_last=None:仅再时间序列最后1天(’day’)或1小时(’hr’)寻找异常
  • threshold=None:仅报告高于指定阈值的正向异常。选项有:
    • med_max:每日最大值的中位数
    • p95:每日最大值的95%
    • p99:每日最大值的99%
  • e_value=False:返回数据中新增一列期望值(加了后都是NaN,不清楚原因)
  • longterm=False:当时间序列超过一个月时,设置此值,
  • piecewise_median_period_weeks=2:当设置longterm后需要设置该值,设置滑动窗口的大小,注意这里需要>=2
  • plot=False:输出图像,已经不支持
  • y_log=False:对Y轴值取对数
  • xlabel = ”:添加输出到图形的X轴标签
  • ylabel = ‘count’:添加输出到图形的Y轴标签
  • title=None:输出图像的标签
  • verbose=False:是否输出debug信息

移动平均/加权移动平均/指数加权移动平均

移动平均 moving average

给定一个时间序列和窗口长度N,moving average等于当前data point之前N个点(包括当前点)的平均值。不停地移动这个窗口,就得到移动平均曲线。

累加移动平均 cumulative moving average

设$\{x_i:i \ge 1\}$是观察到的数据序列。 累积移动平均线是所有数据的未加权平均值。 如果若干天的值是$x_1,…,x_i$,那么$CMA_i=\frac{x_1+…+x_i}{i}$

当有新的值$ x_{i+1} $,那么累积移动平均为:

$$\begin{align*} CMA_{i+1} &= \frac{x_1+…+x_i+x_{i+1}}{i+1} \\ & = \frac{x_{i+1}+n·CMA_i}{i+1}\\ & = CMA_i + \frac{x_{i+1}-CMA_i}{i+1} \end{align*}$$

加权移动平均 weighted moving average

加权移动平均值是先前w个数据的加权平均值。假设$\sum_{j=0}^{w-1}weight_j=1$,其中$weight_j>0$,则加权移动平均值为$WMA_i=\sum_{j=0}^{w-1}weight_j \dot x_{i-j}$

一般$weight_j = \frac{w-j}{w+(w-1)+…+1}, for\ 0\le j \le w-1$,所以:$WMA_i=\frac{wx_i+(w-1)x_{i-1}+…+2x_{i-w+2}+x_{i-w+1}}{w+(w-1)+…+1}$

指数加权移动平均 exponential weighted moving average

指数移动与移动平均有些不同:

  • 并没有时间窗口,用的是从时间序列第一个data point到当前data point之间的所有点。
  • 每个data point的权重不同,离当前时间点越近的点的权重越大,历史时间点的权重随着离当前时间点的距离呈指数衰减,从当前data point往前的data point,权重依次为$\alpha ,\alpha (1-\alpha),\alpha (1-\alpha)^2,…,\alpha (1-\alpha)^n$。

该算法可以检测一个异常较短时间后发生另外一个异常的情况,异常持续一段时间后可能被判定为正常。

双指数平滑 double exponential smoothing (Holt-Linear)

假设$\{Y_t:t\ge1\}$是观测数据序列,有两个与双指数平滑相关的方程:

$$S_t = \alpha Y_t+(1-\alpha)(S_{t-1}+b_{t-1})$$

$$b_t = \beta(S_t-S_{t-1})+(1-\beta)b_{t-1}$$

其中$\alpha \in [0,1]$是数据平滑因子,$\beta \in [0,1]$是趋势平滑因子。

这里,初始值为$S_1=Y_1$,$b_1$有三种可能:

$$b_1 = Y_2-Y_1$$

$$b_1 = \frac{(Y_2-Y_1)+(Y_3-Y_2)+(Y_4-Y_3)}{3}=\frac{Y_4-Y_1}{3}$$

$$b_1 = \frac{Y_n-Y_1}{n-1}$$

预测:

  • 单个时刻:$F_{t+1}=S_t+b_t$
  • m个时刻:$F_{t+m}=S_t+mb_t$

三指数平滑 triple exponential smoothing (Holt-Winters)

multiplicative seasonality

设$\{Y_t:t \ge 1\}$是观察到的数据序列,则三指数平滑为:

$$S_t = \alpha \frac{Y_t}{c_{t-L}}+(1-\alpha)(S_{t-1}+b_{t-1}),overall\ smoothing $$

$$b_t = \beta(S_t-S_{t-1})+(1-\beta)b_{t-1},trend\ smoothing$$

$$c_t = \gamma\frac{Y_t}{S_t}+(1-\gamma)c_{t-L}, seasonal smoothing$$

其中$\alpha \in [0,1]$是数据平滑因子,$\beta \in [0,1]$是趋势平滑因子,$\gamma \in [0,1]$是季节变化平滑因子。

预测m个时刻:$F_{t+m}=(S_t+mb_t)c_{(t-L+m)\ mod\ L}$

初始值设定:

$$S_1 = Y_1$$

$$b_0 = \frac{(Y_{L+1}-Y_1)+(Y_{L+2}-Y_2)+…+(Y_{L+L}-Y_L)}{L}$$

$$c_i = \frac{1}{N}\sum_{j=1}^N\frac{Y_{L(j-1)+i}}{A_j}, \forall i \in \{1,…,L\}$$

$$A_j=\frac{\sum_{i=1}^LY_{L(j-1)+i}}{L}, \forall \in \{1,…,N\}$$

additive seasonality

设$\{Y_t:t \ge 1\}$是观察到的数据序列,则三指数平滑为:

$$S_t = \alpha(Y_t-c_{t-L})+(1-\alpha)(S_{t-1}+b_{t-1}), overall \ smoothing $$

$$b_t = \beta(S_t- S_{t-1})+(1-\beta)b_{t-1}, trend \ smoothing $$

$$c_t = \gamma(Y_t-S_{t-1}-b_{t-1})+(1-\gamma)c_{t-L}, seasonal smoothing$$

其中$\alpha \in [0,1]$是数据平滑因子,$\beta \in [0,1]$是趋势平滑因子,$\gamma \in [0,1]$是季节变化平滑因子。

预测m个时刻:$F_{t+m}=S_t+mb_t+c_{(t-L+m)\ mod \ L}$

ARIMA 模型

自回归移动平均模型(ARIMA)是一种设计上非常简单的方法,但其效果足够强大,可以预测信号并发现其中的异常。该方法的思路是从过去的几个数据点来生成下一个数据点的预测,在过程中添加一些随机变量(通常是添加白噪声)。以此类推,预测得到的数据点可以用来生成新的预测。很明显:它会使得后续预测信号数据更平滑。使用这种方法最困难的部分是选择差异数量、自回归数量和预测误差系数。另一个障碍是信号经过差分后应该是固定的。也就是说,这意味着信号不应该依赖于时间,这是一个比较显著的限制。

异常检测是利用离群点来建立一个经过调整的信号模型,然后利用t-统计量来检验该模型是否比原模型能更好的拟合数据。

该方法最受欢迎的实现是 R 语言中的 tsoutliers 包。在这种情况下,你可以找到适合信号的 ARIMA 模型,它可以检测出所有类型的异常。

神经网络

与CART方法一样,神经网络有两种应用方式:监督学习和无监督学习。我们处理的数据是时间序列,所以最适合的神经网络类型是 LSTM。如果构建得当,这种循环神经网络将可以建模实现时间序列中最复杂的依赖关系,包括高级的季节性依赖关系。如果存在多个时间序列相互耦合,该方法也非常有用。该领域还在研究中,可以参考这里,构建时序模型需要大量的工作。构建成功完成后,就可能在精确度方面取得优异的成绩。

时间序列异常检测方案

360时间序列检测机制

短期环比(SS)

对于时间序列来说,T 时刻的数值对于 T-1 时刻有很强的依赖性。比如流量在8:00很多,在8:01时刻的概率是很大的,但是07:01时刻对于8:01时刻影响不是很大。

首先,我们可以使用最近时间窗口(T)内的数据遵循某种趋势的现象来做文章。比如我们将 T 设置为7,则我们取检测值(now_value)和过去7个(记为 i)点进行比较,如果大于阈值我们将 count 加1,如果 count 超过我们设置的 count_num,则认为该点是异常点。

$$count(\sum_{i=0}^{T}(nowvalue-i)>threshold)>count\_num$$

上面的公式涉及到threshold和count_num两个参数, count_num可以根据的需求进行设置,比如对异常敏感,可以设置count_num小一些,而如果对异常不敏感,可以将count_num设置的大一些。

Threshold可以采用动态阈值。业界关于动态阈值设置的方法有很多,通常阈值设置方法会参考过去一段时间内的均值、最大值以及最小值,我们也同样应用此方法。取过去一段时间(比如 T 窗口)的平均值、最大值以及最小值,然后取 max-avg 和 avg-min 的最小值。之所以取最小值的原因是让筛选条件设置的宽松一些,让更多的值通过此条件,减少一些漏报的事件。

$$threshold = min(max-avg,avg-min)$$

长期环比(LS)

短期环比参考的是短期内的数据,而仅仅有短期内的数据是不够的,我们还需要参考更长时间内数据的总体走势。

通常使用一条曲线对该趋势进行拟合来反应曲线的走势,如果新的数据打破了这种趋势,使曲线变得不平滑,则该点就出现了异常。曲线拟合的方法有很多,比如回归、moving aver-age、…。在这我们使用 EWMA,即指数权重移动平均方法来拟合曲线。在 EWMA 中,下一点的平均值是由上一点的平均值,加上当前点的实际值修正而来。对于每一个 EWMA 值,每个数据的权重是不一样的,越近的数据将拥有越高的权重。有了平均值之后,我们就可以使用 3-sigma 理论来判断新的 input 是否超过了容忍范围。比较实际的值是否超出了这个范围就可以知道是否可以告警了。

同比(chain)

很多监控项都具有一定的周期性,其中以一天为周期的情况比较常见。为了将监控项的周期性考虑进去,我们选取了某个监控项过去14天的数据。对于某个时刻,将得到14个点可以作为参考值,我们记为 $x_i$,其中 i=1,…,14。

我们先考虑静态阈值的方法来判断 input 是否异常(突增和突减)。如果 input 比过去14天同一时刻的最小值乘以一个阈值还小,就会认为该输入为异常点(突减);而如果 input 比过去14天同一时刻的最大值乘以一个阈值还大,就会认为该输入为异常点(突增)。

静态阈值的方法是根据历史经验得出的值,实际中如何给 max_threshold 和 min_threshold 是一个需要讨论的话题。根据目前动态阈值的经验规则来说,取平均值是一个比较好的思路。

同比振幅(CA)

过去14天的历史曲线必然会比今天的曲线低很多。假设今天出了一个小故障,曲线下跌了,相对于过去14天的曲线仍然是高很多的。这样的故障,使用方法二就检测不出来,那么我们将如何改进我们的方法呢?

怎么计算 t 时刻的振幅呢?我们使用$ \frac{x_{(t)}-x_{(t-1)}}{x_{(t-1)}}$来表示振幅。举个例子,例如 t 时刻的流量为 900bit,t-1 时刻的是 1000bit,那么可以计算出掉线人数是10%。如果参考过去的数据,我们会得到14个振幅值。使用14个振幅的绝对值作为标准,如果 amplitudethreshold 小于 m 时刻的振幅$\frac{m_{(t)}-m_{(t-1)}}{m_{(t-1)}}$并且 m 时刻的振幅大于0,则我们认为该时刻发生突增,而如果 m 时刻的振幅大于 amplitudethreshold 并且 m 时刻的振幅小于0, 则认为该时刻发生突减。

$$amplitude=max[\left | \frac{x_i(t)-x_i(t-1)}{x_i(t-1}\right |],x=1,2,…,14$$

算法组合

上面介绍了四种方法,这四种方法里面,SS 和 LS 是针对非周期性数据的验证方法,而 chain 和 CA 是针对周期性数据的验证方法。那这四种方法应该如何选择和使用呢?下面我们介绍两种使用方法:

1、根据周期性的不同来选择合适的方法。这种方法需要首先验证序列是否具有周期性,如果具有周期性则进入左边分支的检测方法,如果没有周期性则选择进入右分支的检测方法。

上面涉及到了如何检测数据周期的问题,我们可以使用差分的方法来检测数据是否具有周期性。比如取最近两天的数据来做差分,如果是周期数据,差分后就可以消除波动,然后结合方差阈值判断的判断方法来确定数据的周期性。当然,如果数据波动范围比较大,可以在差分之前先对数据进行归一化(比如 z-score)。

2、不区分周期性,直接根据“少数服从多数”的方法来去检测,这种方法比较好理解,在此就不说明了。

参考链接:时间序列异常检测机制的研究

美团外卖订单量预测异常报警模型

异常检测主要有两种策略:

  • 异常驱动的异常检测(敏感性):宁愿误报,也不能错过任何一个异常,这适用于非常重要的检测。简单概括,就是“宁可错杀一千,不能放过一个”。
  • 预算驱动的异常检测(准确性):这种策略的异常检测,从字面理解就是只有定量的一些预算去处理这些报警,那么只能当一定是某种问题时,才能将报警发送出来。

这两种策略不可兼容的。对于检测模型的改善,可以从两个方面入手,一是预测器的优化,二是比较器的优化。我们从这两个方面描述模型的改善。

预测器,就是用一批历史数据预测当前的数据。使用的历史数据集大小,以及使用的预测算法都会影响最终的预测效果。外卖订单量具有明显的周期性,同时相邻时刻的订单量数据也有很强的相关性,我们的目标,就是使用上面说的相关数据预测出当前的订单量。下面,我们分析几种常用的预测器实现。

同比环比预测器

同比环比是比较常用的异常检测方式,它是将当前时刻数据和前一时刻数据(环比)或者前一天同一时刻数据(同比)比较,超过一定阈值即认为该点异常。如果将不同日期、时刻的监控数据以矩阵方式存储,每一行表示一天内不同时刻的监控数据,每一列表示同一时刻不同日期的监控数据,那么存储矩阵如下图所示:

假如需要预测图中黄色数据,那么环比使用图中的蓝色数据作为预测黄点的源数据,同比使用图中红色数据作为预测黄点的源数据。

基线预测器

同比环比使用历史上的单点数据来预测当前数据,误差比较大。t时刻的监控数据,与 t-1,t-2,…时刻的监控数据存在相关性。同时,与t-k,t-2k,…时刻的数据也存在相关性(k为周期),如果能利用上这些相关数据对t时刻进行预测,预测结果的误差将会更小。

比较常用的方式是对历史数据求平均,然后过滤噪声,可以得到一个平滑的曲线(基线),使用基线数据来预测当前时刻的数据。该方法预测t时刻数据(图中黄色数据)使用到的历史数据如下图所示(图中红色数据):

基线数据预测器广泛应用在业务大盘监控中,预测效果如图所示。从图中可以看出,基线比较平滑,在低峰期预测效果比较好,但是在外卖的午高峰和晚高峰预测误差比较大。

Holt-Winters预测器

同比环比预测到基线数据预测,使用的相关数据变多,预测的效果也较好。但是基线数据预测器只使用了周期相关的历史数据,没有使用上同周期相邻时刻的历史数据,相邻时刻的历史数据对于当前时刻的预测影响是比较大的。如外卖订单量,某天天气不好,很多用户不愿意出门,那么当天的外卖的订单量就会呈现整体的上涨,这种整体上涨趋势只能从同一周期相邻时刻的历史数据中预测出来。如图,预测图中黄色数据,如果使用上图中所有的红色数据,那么预测效果会更好。

本文使用了Holt-Winters来实现这一目标。Holt-Winters是三次指数滑动平均算法,它将时间序列数据分为三部分:残差数据a(t),趋势性数据b(t),季节性数据s(t)。使用Holt-Winters预测t时刻数据,需要t时刻前包含多个周期的历史数据。相关链接:Exponential smoothingHolt-Winters seasonal method

外卖报警模型中的预测器

在外卖订单量异常检测中,使用Holt-Winters预测器实时预测下一分钟订单量,每次需要至少5天以上的订单量数据才能有较好的预测效果,数据量要求比较大。在实际的异常检测模型中,我们对Holt-Winters预测器进行了简化。预测器的趋势数据表示的是时间序列的总体变化趋势,如果以天为周期看待外卖的订单量时间序列,是没有明显的趋势性的,因此,我们可以去掉其中的趋势数据部分。

计算序列的周期性数据

时间序列的周期性数据不需要实时计算,按周期性更新即可,如外卖订单大盘监控,s(t)只需要每天更新一次即可。对于s(t)的计算,可以有多种方法,可以使用上面提到的Holt-Winters按公式计算出时间序列的周期性数据,或直接使用前一天的监控数据作为当天的周期数据(这两种方式都需要对输入序列进行预处理,保证算法的输入序列不含有异常数据)。也可以将历史数据做平均求出基线作为序列的周期性数据。

目前外卖订单中心报警模型采用的是Holt-Winters计算周期数据的方式。在将该模型推广到外卖其他业务线监控时,使用了计算基线数据作为周期数据的方式,这里简单对比一下两种方式的优劣。

1、使用Holt-Winters算法计算周期数据

  • 优点:如果序列中含有周期性的陡增陡降点,Holt-Winters计算出的周期数据中会保留这些陡增陡降趋势,因此可以准确的预测出这些趋势,不会产生误报。比如外卖订单的提单数据,在每天的某个时刻都有一个定期陡降,使用该方式可以正确的预测出下降的趋势。如图所示,蓝色线是真实数据,棕色线是预测数据,在该时刻,棕色线准确的预测出了下降点。
  • 缺点:需要对输入数据进行预处理,去除异常数据。如果输入序列中含有异常数据,使用Holt-Winters时可能会把这些异常数据计算到周期数据中,影响下一周期的预测从而产生误报(Holt-Winters理论上也只是滑动平均的过程,因此如果输入数据中含有比较大的异常数据时,存在这种可能性,实际应用中订单的报警模型也出现过这种误报)。

2、历史数据平均求基线

  • 优点:计算出的周期数据比较平滑,不需要对输入序列进行预处理,计算过程中可以自动屏蔽掉异常数据的影响,计算过程简单。
  • 缺点:周期数据比较平滑,不会出现陡增陡降点,因此对于周期性出现的陡增陡降不能很好的预测,出现误报。比如外卖活动的大盘(如图所示,红线是真实数据,黑线是预测数据),提前下单优惠在每天某个时刻会出现周期性陡降,使用该方式会出现误报。

两种求周期数据的方式各有优劣,可以根据各自的监控数据特点选择合适的计算方式。如果监控数据中含有大量的周期性的陡增陡降点,那么推荐使用方式1,可以避免在这些时间点的误报。如果监控数据比较平滑,陡增陡降点很少,那么推荐方式2,计算简单的同时,也能避免因输入数据预处理不好而造成的意料之外的误报。

残差数据实时预测

计算出周期数据后,下一个目标就是对残差数据的预测。实际监控数据与周期数据相减得到残差数据,对残差数据做一次滑动平均,预测出下一刻的残差,将该时刻的残差、周期数据相加即可得到该时刻的预测数据。残差序列的长度设为60,即可以得到比较准确的预测效果。

对于实时预测,使用的是当天的周期数据和前60分钟数据。最终的预测结果如图所示,其中蓝色线是真实数据,红色线是预测数据。

预测器预测出当前时刻订单量的预测值后,还需要与真实值比较来判断当前时刻订单量是否异常。一般的比较器都是通过阈值法,比如实际值超过预测值的一定比例就认为该点出现异常,进行报警。这种方式错误率比较大。在订单模型的报警检测中没有使用这种方式,而是使用了两个串联的Filter,只有当两个Fliter都认为该点异常时,才进行报警,下面简单介绍一下两个Filter的实现。

  • 离散度Filter:根据预测误差曲线离散程度过滤出可能的异常点。一个序列的方差表示该序列离散的程度,方差越大,表明该序列波动越大。如果一个预测误差序列方差比较大,那么我们认为预测误差的报警阈值相对大一些才比较合理。离散度Filter利用了这一特性,取连续15分钟的预测误差序列,分为首尾两个序列(e1,e2),如果两个序列的均值差大于e1序列方差的某个倍数,我们就认为该点可能是异常点。
  • 阈值Filter:根据误差绝对值是否超过某个阈值过滤出可能的异常点。利用离散度Filter进行过滤时,报警阈值随着误差序列波动程度变大而变大,但是在输入数据比较小时,误差序列方差比较小,报警阈值也很小,容易出现误报。所以设计了根据误差绝对值进行过滤的阈值Filter。阈值Filter设计了一个分段阈值函数y=f(x),对于实际值x和预测值p,只有当|x-p|>f(x)时报警。实际使用中,可以寻找一个对数函数替换分段阈值函数,更易于参数调优。

最终的外卖订单异常报警模型结构图如图所示,每天会有定时Job从ETL中统计出最近10天的历史订单量,经过预处理模块,去除异常数据,经过周期数据计算模块得到周期性数据。对当前时刻预测时,取60分钟的真实数据和周期性数据,经过实时预测模块,预测出当前订单量。将连续15分钟的预测值和真实值通过比较器,判断当前时刻是否异常。

参考链接:外卖订单量预测异常报警模型实践

其他参考:

发表回复

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