用Python預測「週期性時間序列」的正確姿勢

雙er發表於2018-03-30

這是當初剛進公司時,leader給的一個獨立練手小專案,關於時間序列預測,情景比較簡單,整個過程實現下來程式碼也僅100多行,但完成過程中踩了很多坑,覺的有必要分(tu)享(cao)一下。完整程式碼和樣例資料放到了我的github上(文章僅貼上部分): https://github.com/scarlettgin/cyclical_series_predict

1、背景

公司平臺上有不同的api,供內部或外部呼叫,這些api承擔著不同的功能,如查詢賬號、發版、搶紅包等等。日誌會記錄下每分鐘某api被訪問了多少次,即一個api每天會有1440條記錄(1440分鐘),將每天的資料連起來觀察,有點類似於股票走勢的意思。我想通過前N天的歷史資料預測出第N+1天的流量訪問情況,預測值即作為合理參考,供新一天與真實值做實時對比。當真實流量跟預測值有較大出入,則認為有異常訪問,觸發報警。

2、資料探索

我放了一份樣例資料在data資料夾下, 看一下資料大小和結構

data = pd.read_csv(filename)
print('size: ',data.shape)
print(data.head())
複製程式碼

data.png

資料大小: 共10080條記錄,即10080分鐘,七天的資料。 欄位含義: date:時間,單位分鐘 count:該分鐘該api被訪問的次數

畫圖看一下序列的走勢:(一些畫圖等探索類的方法放在了test_stationarity.py 檔案中,包含時間序列圖,移動平均圖,有興趣的可以自己嘗試下)。

def draw_ts(timeseries):
    timeseries.plot()
    plt.show()

data = pd.read_csv(path)
data = data.set_index('date')
data.index = pd.to_datetime(data.index)
ts = data['count']
draw_ts(ts)
複製程式碼

序列.png

看這糟心的圖,那些驟降為0的點這就是我遇到的第一個坑,我當初一拿到這份資料就開始做了。後來折騰了好久才發現,那些驟降為0的點是由於資料缺失,ETL的同學自動補零造成的,溝通晚了(TДT)。

把坑填上,用前後值的均值把缺失值補上,再看一眼:

填充好缺失值的序列.png

發現這份資料有這樣幾個特點,在模型設計和資料預處理的時候要考慮到:

1、這是一個週期性的時間序列,數值有規律的以天為週期上下波動,圖中這個api,在每天下午和晚上訪問較為活躍,在早上和凌晨較為稀少。在建模之前需要做分解。

2、我的第二個坑:資料本身並不平滑,驟突驟降較多,而這樣是不利於預測的,畢竟模型需要學習好正常的序列才能對未知資料給出客觀判斷,否則會出現頻繁的誤報,令氣氛變得十分尷尬( ´Д`),所以必須進行平滑處理。

3、這只是一個api的序列圖,而不同的api的形態差距是很大的,畢竟承擔的功能不同,如何使模型適應不同形態的api也是需要考慮的問題。

3、預處理

3.1 劃分訓練測試集

前六天的資料做訓練,第七天做測試集。

class ModelDecomp(object):
    def __init__(self, file, test_size=1440):
        self.ts = self.read_data(file)
        self.test_size = test_size
        self.train_size = len(self.ts) - self.test_size
        self.train = self.ts[:len(self.ts)-test_size]
        self.test = self.ts[-test_size:]
複製程式碼

3.2 對訓練資料進行平滑處理

消除資料的毛刺,可以用移動平均法,我這裡沒有采用,因為我試過發現對於我的資料來說,移動平均處理完後並不能使資料平滑,我這裡採用的方法很簡單,但效果還不錯:把每個點與上一點的變化值作為一個新的序列,對這裡邊的異常值,也就是變化比較離譜的值剃掉,用前後資料的均值填充,注意可能會連續出現變化較大的點:

def _diff_smooth(self, ts):
    dif = ts.diff().dropna() # 差分序列
    td = dif.describe() # 描述性統計得到:min,25%,50%,75%,max值
    high = td['75%'] + 1.5 * (td['75%'] - td['25%']) # 定義高點閾值,1.5倍四分位距之外
    low = td['25%'] - 1.5 * (td['75%'] - td['25%']) # 定義低點閾值,同上

    # 變化幅度超過閾值的點的索引
    forbid_index = dif[(dif > high) | (dif < low)].index 
    i = 0
    while i < len(forbid_index) - 1:
        n = 1 # 發現連續多少個點變化幅度過大,大部分只有單個點
        start = forbid_index[i] # 異常點的起始索引
        while forbid_index[i+n] == start + timedelta(minutes=n):
            n += 1
        i += n - 1

        end = forbid_index[i] # 異常點的結束索引
        # 用前後值的中間值均勻填充
        value = np.linspace(ts[start - timedelta(minutes=1)], ts[end + timedelta(minutes=1)], n)
        ts[start: end] = value
        i += 1

self.train = self._diff_smooth(self.train)
draw_ts(self.train)
複製程式碼

平滑後的訓練資料:

平滑後的訓練序列.png

3.3 將訓練資料進行週期性分解

採用statsmodels工具包:

from statsmodels.tsa.seasonal import seasonal_decompose

decomposition = seasonal_decompose(self.ts, freq=freq, two_sided=False)
# self.ts:時間序列,series型別; 
# freq:週期,這裡為1440分鐘,即一天; 
# two_sided:觀察下圖2、4行圖,左邊空了一段,如果設為True,則會出現左右兩邊都空出來的情況,False保證序列在最後的時間也有資料,方便預測。

self.trend = decomposition.trend
self.seasonal = decomposition.seasonal
self.residual = decomposition.resid
decomposition.plot()
plt.show()
複製程式碼

分解圖.png

第一行observed:原始資料;第二行trend:分解出來的趨勢部分;第三行seasonal:週期部分;最後residual:殘差部分。 我採用的是seasonal_decompose的加法模型進行的分解,即 observed = trend + seasonal + residual,另還有乘法模型。在建模的時候,只針對trend部分學習和預測,如何將trend的預測結果加工成合理的最終結果?當然是再做加法,後面會詳細寫。

4、模型

4.1 訓練

對分解出來的趨勢部分單獨用arima模型做訓練:

def trend_model(self, order):
    self.trend.dropna(inplace=True)
    train = self.trend[:len(self.trend)-self.test_size]
    #arima的訓練引數order =(p,d,q),具體意義檢視官方文件,調參過程略。
    self.trend_model = ARIMA(train, order).fit(disp=-1, method='css')
複製程式碼

4.2 預測

預測出趨勢資料後,加上週期資料即作為最終的預測結果,但更重要的是,我們要得到的不是具體的值,而是一個合理區間,當真實資料超過了這個區間,則觸發報警,誤差高低區間的設定來自剛剛分解出來的殘差residual資料:

d = self.residual.describe()
delta = d['75%'] - d['25%']
self.low_error, self.high_error = (d['25%'] - 1 * delta, d['75%'] + 1 * delta)
複製程式碼

預測並完成最後的加法處理,得到第七天的預測值即高低置信區間:

        def predict_new(self):
            '''
            預測新資料
            '''
            #續接train,生成長度為n的時間索引,賦給預測序列
            n = self.test_size
            self.pred_time_index= pd.date_range(start=self.train.index[-1], periods=n+1, freq='1min')[1:]
            self.trend_pred= self.trend_model.forecast(n)[0]
            self.add_season()


        def add_season(self):
            '''
            為預測出的趨勢資料新增週期資料和殘差資料
            '''
            self.train_season = self.seasonal[:self.train_size]
            values = []
            low_conf_values = []
            high_conf_values = []
            
            for i, t in enumerate(self.pred_time_index):
                trend_part = self.trend_pred[i]
                
                # 相同時間點的週期資料均值
                season_part = self.train_season[
                	self.train_season.index.time == t.time()
                	].mean()
    
                # 趨勢 + 週期 + 誤差界限
                predict = trend_part + season_part
                low_bound = trend_part + season_part + self.low_error
                high_bound = trend_part + season_part + self.high_error
                
                values.append(predict)
                low_conf_values.append(low_bound)
                high_conf_values.append(high_bound)

            # 得到預測值,誤差上界和下界
            self.final_pred = pd.Series(values, index=self.pred_time_index, name='predict')
            self.low_conf = pd.Series(low_conf_values, index=self.pred_time_index, name='low_conf')
            self.high_conf = pd.Series(high_conf_values, index=self.pred_time_index, name='high_conf')
複製程式碼

4.3 評估:

對第七天作出預測,評估的指標為均方根誤差rmse,畫圖對比和真實值的差距:

	md = ModelDecomp(file=filename, test_size=1440)
	md.decomp(freq=1440)
	md.trend_model(order=(1, 1, 3)) # arima模型的引數order
	md.predict_new() 
	pred = md.final_pred
	test = md.test

	plt.subplot(211)
	plt.plot(md.ts) # 平滑過的訓練資料加未做處理的測試資料
	plt.title(filename.split('.')[0])

	plt.subplot(212)
	pred.plot(color='blue', label='Predict') # 預測值
	test.plot(color='red', label='Original') # 真實值
	md.low_conf.plot(color='grey', label='low') # 低置信區間
	md.high_conf.plot(color='grey', label='high') # 高置信區間

	plt.legend(loc='best')
	plt.title('RMSE: %.4f' % np.sqrt(sum((pred.values - test.values) ** 2) / test.size))
	plt.tight_layout()
	plt.show()
複製程式碼

預測結果.png

可以看到,均方根誤差462.8,相對於原始資料幾千的量級,還是可以的。測試資料中的兩個突變的點,也超過了置信區間,能準確報出來。

5、結語

前文提到不同的api形態差異巨大,本文只展示了一個,我在該專案中還接觸了其他形態的序列,有的有明顯的上升或下降趨勢;有的開始比較平緩,後面開始增長... ... ,但是都屬於典型的週期性時間序列,它的核心思想很簡單:做好分解,做好預測結果的還原,和置信區間的設定,具體操作可根據具體業務邏輯做調整,祝大家建模愉快:-D。

相關文章