Python量化投資——包含NA值的時間序列移動平均值計算效率比較
目的
之所以要提出這個題目,是因爲處理包含NA值的時間序列移動平均值計算在量化投資領域中是一個跨不過去的坎:最典型的應用是針對幾隻股票的歷史數據計算移動平均值。在股票的歷史數據中,不可避免地某隻或某幾隻股票都會出現停牌等情況導致某些交易日的價格不存在,通常這樣的數據會在pandas中使用Nan值來代表,比如下面的數據:
上面的圖表中顯示了六隻股票在15年至17年之間的每日收盤價,其中部分股票的價格在某些日期裏是Nan
很多量化擇時策略都需要計算股票價格的移動平均價格,如果股票的價格中不含nan值,移動平均價非常好計算,在pandas中直接使用rolling對象就可以計算。但是,如果數據序列中有nan值的時候,情況就不那麼簡單,如果直接用以下方法計算,原始數據中的nan值會對平均價的計算造成影響:
例如,我們假設有一隻股票的十日股價如下,其中第7日的股價爲Nan值:
In [76]: ser
Out[76]:
0 0.0
1 8.0
2 3.0
3 4.0
4 7.0
5 0.0
6 3.0
7 NaN
8 2.0
9 0.0
Name: 1, dtype: float64
如果使用pandas的rolling對象直接計算移動平均值,第7日的Nan值將導致後續幾天的移動平均值全都是Nan:
In [77]: ser.rolling(3).mean()
Out[77]:
0 NaN
1 NaN
2 3.666667
3 5.000000
4 4.666667
5 3.666667
6 3.333333
7 NaN
8 NaN
9 NaN
Name: 1, dtype: float64
這樣顯然不符合我們的要求。我們實際需要的是在計算移動平均值以前,先把第7日Nan值剔除掉,以便計算連續的移動平均值,計算完成後再把相應的結果填充到原來的日期中,使第7日仍然爲Nan,也就是說,第8日的移動平均由第5、6、8三天的數據計算而來。
因此,應該採用下面的方法:
In [78]: result = ser.dropna().rolling(3).mean()
Out[79]:
0 NaN
1 NaN
2 3.666667
3 5.000000
4 4.666667
5 3.666667
6 3.333333
8 1.666667
9 1.666667
Name: 1, dtype: float64
In [81]: result.reindex(ser.index)
Out[81]:
0 NaN
1 NaN
2 3.666667
3 5.000000
4 4.666667
5 3.666667
6 3.333333
7 NaN
8 1.666667
9 1.666667
Name: 1, dtype: float64
上面的代碼使用pd.notna()提取出所有不含nan值的數據,計算完成移動平均(注意此時Nan值被完全忽略,不會對後續幾天的移動平均值造成影響),計算完成後,再用reindex()恢復原來的日期標籤。
由於移動平均在量化投資的回測算法中是一個需要大量重複的函數,因此很有必要對幾種快速執行移動平均的算法進行效率上的比較,下面所用一個實例來測試幾種不同的方法的計算效率。
測試數據包括一個3000x2500的矩陣,代表3000只股票的數據,每隻股票有2500個價格數據,其中每隻股票都有一個或幾個Nan值數據(可以看到2498行全部是Nan,2496和2497行有部分Nan值)。下面代碼的任務就是要對每隻股票計算MA(3) - MA(5)的值:
In [84]: df
Out[84]:
0 1 2 3 4 ... 2995 2996 2997 2998 2999
0 7.0 -3.0 -2.0 0.0 4.0 ... 6.0 -1.0 2.0 -1.0 2.0
1 3.0 -3.0 5.0 5.0 -3.0 ... 6.0 1.0 5.0 1.0 -3.0
2 6.0 0.0 -3.0 -3.0 0.0 ... -1.0 6.0 2.0 2.0 -3.0
3 4.0 -1.0 -2.0 6.0 1.0 ... 4.0 -2.0 3.0 5.0 4.0
4 9.0 4.0 5.0 6.0 -1.0 ... 5.0 6.0 6.0 -1.0 0.0
... ... ... ... ... ... ... ... ... ... ... ...
2495 4.0 4.0 -3.0 -3.0 1.0 ... 6.0 1.0 0.0 -1.0 2.0
2496 6.0 -1.0 -2.0 2.0 -1.0 ... 0.0 NaN NaN NaN 2.0
2497 7.0 5.0 -3.0 -3.0 3.0 ... 1.0 NaN NaN NaN 3.0
2498 NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN
2499 3.0 -2.0 -1.0 4.0 1.0 ... -3.0 -3.0 -2.0 4.0 -1.0
[2500 rows x 3000 columns]
基於pandas迭代器的方法
衆所周知python自帶的for循環是速度非常慢的,因此直接放棄。
最直接的方法是基於pandas的iteritems()迭代器方法:
In [21]: def iter_ma(df):
...: result = df.copy()
...: for i, c in df.iteritems():
...: c_ = c.dropna()
...: result[i] = c_.rolling(3).mean() - c_.rolling(5).mean()
...:
...: return result
基於list的方法
上面的方法創建df的一個拷貝,並且對該拷貝的每個Series直接賦值
另一種方法是使用list對象來存儲所有計算完成的結果,最後再用pd.DataFrame組裝成一個新的dataFrame:
In [16]: def list_ma(df):
...: result = []
...: for i, c in df.iteritems():
...: c_ = c.dropna()
...: result.append(c_.rolling(3).mean() - c_.rolling(5).mean()
...: )
...: return pd.DataFrame(result, index = df.columns, columns = df.
...: index).T
雖然在最終結果處理方面的做法不一樣,但本質上還是iteritems迭代器執行的
基於apply的方法
爲了避免使用迭代器,可以使用pandas中的apply()函數,將一個函數直接應用到df的對應軸上,因此需要首先定義對一個series的操作函數:
In [28]: def ma_pd(ser):
...: ser_ = ser.dropna()
...: return ser_.rolling(3).mean() - ser_rolling(5).mean()
...:
In [29]: def apply_ma(df):
...: result = df.copy()
...: return result.apply(ma_pd, axis = 0)
...:
基於numpy結合pandas的方法
更快的方法應該是將DataFrame轉化爲Numpy的ndarray數組來進行操作,但是需要解決的問題仍然是如何恢復操作前剔除掉的Nan值上:
在numpy中我們可以使用isnan()函數來生成一個邏輯序列,判斷數組中的值是否是nan值。不過,在numpy中並沒有原生的移動平均值計算函數,因此我們只能自己寫一個,同樣因爲效率的緣故,不能使用循環,代碼如下:
In [40]: def moving_avg(arr, w):
...: a = arr.cumsum()
...: ar = np.roll(a, w)
...: ar[:w-1] = np.nan
...: ar[w-1] = 0
...: return (a - ar) / w
這個moving average函數的計算速度比pandas的rolling.mean()函數的速度快大約五倍。但這並不是瓶頸,最大的瓶頸是對Series的對象操作,因此接下來還需要一個函數將Series轉化爲ndarray,完成移動平均計算後,再轉回Series。代碼如下:
In [97]: def ser_ma(ser):
...: v = ser.values
...: drop = ~np.isnan(v)
...: i = ser.index[drop]
...: return pd.Series(moving_avg(v[drop], 3) - moving_avg(v[drop], 5), i)
以上函數可以對一個Series完成忽略NAN值的移動平均計算,我們用apply()來將它應用到整個DataFrame對象,代碼如下:
In [92]: def numpy_ma(df):
...: result = df.copy()
...: return result.apply(ser_ma, axis = 0)
基於純Numpy的方法
上面的方法其實只是用numpy結合了pandas,更純粹的方法是將整個DataFrame轉化爲ndarray,全部計算完成後再轉回DataFrame
因爲是純Numpy,因此moving-avg函數就需要重新寫:
In [232]: def val_ma(arr):
...: a = arr.copy()
...: a_ = arr[~np.isnan(arr)]
...: a[~np.isnan(arr)] = moving_avg(a_, 3) - moving_avg(a_, 5)
...: return a
然後將上述函數apply到ndarray的每一列即可(axis=0),代碼如下:
In [273]: def pnumpy_ma(df):
...: v = df.values
...: return pd.DataFrame(np.apply_along_axis(val_ma, 0, v), index = df.index, columns=df.columns)
速度比較總結
對五種方法進行速度比較,會發現所有基於pandas的方法本質上速度相差不大,apply方法稍快於迭代的方法,而兩種迭代方法中,使用list組裝的方式稍快。
速度明顯更快的是採用numpy的方法,速度大約是pandas方法的三至四倍:
而純numpy的方法自然是拿到了效率冠軍,提升超過20倍
In [276]: %timeit iter_ma(df)
4.63 s ± 20.4 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [277]: %timeit list_ma(df)
4.2 s ± 24.4 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [278]: %timeit apply_ma(df)
4.17 s ± 16.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [279]: %timeit numpy_ma(df)
1.3 s ± 2.33 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [280]: %timeit pnumpy_ma(df)
289 ms ± 11 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)