Python量化投资——包含NA值的时间序列移动平均值计算效率比较

  • 目的
  • 基于pandas迭代器的方法
  • 基于list的方法
  • 基于apply的方法
  • 基于numpy结合pandas的方法
  • 基于纯Numpy的方法
  • 速度比较总结


目的

之所以要提出这个题目,是因为处理包含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)