重采样及频率转换

重采样(resampling)指的是将时间序列从一个频率转换到另一个频率的处理过程。将高频率数据聚合到低频率称为降采样(downsampling),而将低频率数据转换到高频率则称为升采样(upsampling)。并不是所有的重采样都能被划分到这两个大类中。例如,将W-WED(每周三)转换为W-FRI既不是降采样也不是升采样。

pandas对象都带有一个resample方法,它是各种频率转换工作的主力函数:

  1. In [509]: rng = pd.date_range('1/1/2000', periods=100, freq='D')
  2.  
  3. In [510]: ts = Series(randn(len(rng)), index=rng)
  4.  
  5. In [511]: ts.resample('M', how='mean')
  6. Out[511]:
  7. 2000-01-31 0.170876
  8. 2000-02-29 0.165020
  9. 2000-03-31 0.095451
  10. 2000-04-30 0.363566
  11. Freq: M
  12.  
  13. In [512]: ts.resample('M', how='mean', kind='period')
  14. Out[512]:
  15. 2000-01 0.170876
  16. 2000-02 0.165020
  17. 2000-03 0.095451
  18. 2000-04 0.363566
  19. Freq: M

resample是一个灵活高效的方法,可用于处理非常大的时间序列。我将通过一系列的示例说明其用法。

重采样及频率转换 - 图1

重采样及频率转换 - 图2

降采样

将数据聚合到规整的低频率是一件非常普通的时间序列处理任务。待聚合的数据不必拥有固定的频率,期望的频率会自动定义聚合的面元边界,这些面元用于将时间序列拆分为多个片段。例如,要转换到月度频率('M'或'BM'),数据需要被划分到多个单月时间段中。各时间段都是半开放的。一个数据点只能属于一个时间段,所有时间段的并集必须能组成整个时间帧。在用resample对数据进行降采样时,需要考虑两样东西:

·各区间哪边是闭合的。

·如何标记各个聚合面元,用区间的开头还是末尾。

首先,我们来看一些“1分钟”数据:

  1. In [513]: rng = pd.date_range('1/1/2000', periods=12, freq='T')
  2.  
  3. In [514]: ts = Series(np.arange(12), index=rng)
  4.  
  5. In [515]: ts
  6. Out[515]:
  7. 2000-01-01 00:00:00 0
  8. 2000-01-01 00:01:00 1
  9. 2000-01-01 00:02:00 2
  10. 2000-01-01 00:03:00 3
  11. 2000-01-01 00:04:00 4
  12. 2000-01-01 00:05:00 5
  13. 2000-01-01 00:06:00 6
  14. 2000-01-01 00:07:00 7
  15. 2000-01-01 00:08:00 8
  16. 2000-01-01 00:09:00 9
  17. 2000-01-01 00:10:00 10
  18. 2000-01-01 00:11:00 11
  19. Freq: T

假设你想要通过求和的方式将这些数据聚合到“5分钟”块中:

  1. In [516]: ts.resample('5min', how='sum')
  2. Out[516]:
  3. 2000-01-01 00:00:00 0
  4. 2000-01-01 00:05:00 15
  5. 2000-01-01 00:10:00 40
  6. 2000-01-01 00:15:00 11
  7. Freq: 5T

传入的频率将会以“5分钟”的增量定义面元边界。默认情况下,面元的右边界是包含的,因此00:00到00:05的区间中是包含00:05的注1。传入closed='left'会让区间以左边界闭合:

  1. In [517]: ts.resample('5min', how='sum', closed='left')
  2. Out[517]:
  3. 2000-01-01 00:05:00 10
  4. 2000-01-01 00:10:00 35
  5. 2000-01-01 00:15:00 21
  6. Freq: 5T

如你所见,最终的时间序列是以各面元右边界的时间戳进行标记的。传入label='left'即可用面元的左边界对其进行标记:

  1. In [518]: ts.resample('5min', how='sum', closed='left', label='left')
  2. Out[518]:
  3. 2000-01-01 00:00:00 10
  4. 2000-01-01 00:05:00 35
  5. 2000-01-01 00:10:00 21
  6. Freq: 5T

图10-3说明了“1分钟”数据被转换为“5分钟”数据的处理过程。

重采样及频率转换 - 图3

图10-3:各种closed、label约定的“5分钟”重采样演示

最后,你可能希望对结果索引做一些位移,比如从右边界减去一秒以便更容易明白该时间戳到底表示的是哪个区间。只需通过loffset设置一个字符串或日期偏移量即可实现这个目的:

  1. In [519]: ts.resample('5min', how='sum', loffset='-1s')
  2. Out[519]:
  3. 1999-12-31 23:59:59 0
  4. 2000-01-01 00:04:59 15
  5. 2000-01-01 00:09:59 40
  6. 2000-01-01 00:14:59 11
  7. Freq: 5T

此外,也可以通过调用结果对象的shift方法来实现该目的,这样就不需要设置loffset了。

OHLC重采样

金融领域中有一种无所不在的时间序列聚合方式,即计算各面元的四个值:第一个值(open,开盘)、最后一个值(close,收盘)、最大值(high,最高)以及最小值(low,最低)。传入how='ohlc'即可得到一个含有这四种聚合值的DataFrame。整个过程很高效,只需一次扫描即可计算出结果:

  1. In [520]: ts.resample('5min', how='ohlc')
  2. Out[520]:
  3. open high low close
  4. 2000-01-01 00:00:00 0 0 0 0
  5. 2000-01-01 00:05:00 1 5 1 5
  6. 2000-01-01 00:10:00 6 10 6 10
  7. 2000-01-01 00:15:00 11 11 11 11

通过groupby进行重采样

另一种降采样的办法是使用pandas的groupby功能。例如,你打算根据月份或星期几进行分组,只需传入一个能够访问时间序列的索引上的这些字段的函数即可:

  1. In [521]: rng = pd.date_range('1/1/2000', periods=100, freq='D')
  2.  
  3. In [522]: ts = Series(np.arange(100), index=rng)
  4.  
  5. In [523]: ts.groupby(lambda x: x.month).mean()
  6. Out[523]:
  7. 1 15
  8. 2 45
  9. 3 75
  10. 4 95
  11.  
  12. In [524]: ts.groupby(lambda x: x.weekday).mean()
  13. Out[524]:
  14. 0 47.5
  15. 1 48.5
  16. 2 49.5
  17. 3 50.5
  18. 4 51.5
  19. 5 49.0
  20. 6 50.0

升采样和插值

在将数据从低频率转换到高频率时,就不需要聚合了。我们来看一个带有一些周型数据的DataFrame:

  1. In [525]: frame = DataFrame(np.random.randn(2, 4),
  2. ...: index=pd.date_range('1/1/2000', periods=2, freq='W-WED'),
  3. ...: columns=['Colorado', 'Texas', 'New York', 'Ohio'])
  4.  
  5. In [526]: frame[:5]
  6. Out[526]:
  7. Colorado Texas New York Ohio
  8. 2000-01-05 -0.609657 -0.268837 0.195592 0.85979
  9. 2000-01-12 -0.263206 1.141350 -0.101937 -0.07666

将其重采样到日频率,默认会引入缺失值:

  1. In [527]: df_daily = frame.resample('D')
  2.  
  3. In [528]: df_daily
  4. Out[528]:
  5. Colorado Texas New York Ohio
  6. 2000-01-05 -0.609657 -0.268837 0.195592 0.85979
  7. 2000-01-06 NaN NaN NaN NaN
  8. 2000-01-07 NaN NaN NaN NaN
  9. 2000-01-08 NaN NaN NaN NaN
  10. 2000-01-09 NaN NaN NaN NaN
  11. 2000-01-10 NaN NaN NaN NaN
  12. 2000-01-11 NaN NaN NaN NaN
  13. 2000-01-12 -0.263206 1.141350 -0.101937 -0.07666

假设你想要用前面的周型值填充“非星期三”。resampling的填充和插值方式跟fillna和reindex的一样:

  1. In [529]: frame.resample('D', fill_method='ffill')
  2. Out[529]:
  3. Colorado Texas New York Ohio
  4. 2000-01-05 -0.609657 -0.268837 0.195592 0.85979
  5. 2000-01-06 -0.609657 -0.268837 0.195592 0.85979
  6. 2000-01-07 -0.609657 -0.268837 0.195592 0.85979
  7. 2000-01-08 -0.609657 -0.268837 0.195592 0.85979
  8. 2000-01-09 -0.609657 -0.268837 0.195592 0.85979
  9. 2000-01-10 -0.609657 -0.268837 0.195592 0.85979
  10. 2000-01-11 -0.609657 -0.268837 0.195592 0.85979
  11. 2000-01-12 -0.263206 1.141350 -0.101937 -0.07666

同样,这里也可以只填充指定的时期数(目的是限制前面的观测值的持续使用距离):

  1. In [530]: frame.resample('D', fill_method='ffill', limit=2)
  2. Out[530]:
  3. Colorado Texas New York Ohio
  4. 2000-01-05 -0.609657 -0.268837 0.195592 0.85979
  5. 2000-01-06 -0.609657 -0.268837 0.195592 0.85979
  6. 2000-01-07 -0.609657 -0.268837 0.195592 0.85979
  7. 2000-01-08 NaN NaN NaN NaN
  8. 2000-01-09 NaN NaN NaN NaN
  9. 2000-01-10 NaN NaN NaN NaN
  10. 2000-01-11 NaN NaN NaN NaN
  11. 2000-01-12 -0.263206 1.141350 -0.101937 -0.07666

注意,新的日期索引完全没必要跟旧的相交:

  1. In [531]: frame.resample('W-THU', fill_method='ffill')
  2. Out[531]:
  3. Colorado Texas New York Ohio
  4. 2000-01-06 -0.609657 -0.268837 0.195592 0.85979
  5. 2000-01-13 -0.263206 1.141350 -0.101937 -0.07666

通过时期进行重采样

对那些使用时期索引的数据进行重采样是件非常简单的事情:

  1. In [532]: frame = DataFrame(np.random.randn(24, 4),
  2. ...: index=pd.period_range('1-2000', '12-2001', freq='M'),
  3. ...: columns=['Colorado', 'Texas', 'New York', 'Ohio'])
  4.  
  5. In [533]: frame[:5]
  6. Out[533]:
  7. Colorado Texas New York Ohio
  8. 2000-01 0.120837 1.076607 0.434200 0.056432
  9. 2000-02 -0.378890 0.047831 0.341626 1.567920
  10. 2000-03 -0.047619 -0.821825 -0.179330 -0.166675
  11. 2000-04 0.333219 -0.544615 -0.653635 -2.311026
  12. 2000-05 1.612270 -0.806614 0.557884 0.580201
  13.  
  14. In [534]: annual_frame = frame.resample('A-DEC', how='mean')
  15.  
  16. In [535]: annual_frame
  17. Out[535]:
  18. Colorado Texas New York Ohio
  19. 2000 0.352070 -0.553642 0.196642 -0.094099
  20. 2001 0.158207 0.042967 -0.360755 0.184687

升采样要稍微麻烦一些,因为你必须决定在新频率中各区间的哪端用于放置原来的值,就像asfreq方法那样。convention参数默认为'end',可设置为'start':

  1. # Q-DEC: 季度型(每年以12月结束)
  2. In [536]: annual_frame.resample('Q-DEC', fill_method='ffill')
  3. Out[536]:
  4. Colorado Texas New York Ohio
  5. 2000Q4 0.352070 -0.553642 0.196642 -0.094099
  6. 2001Q1 0.352070 -0.553642 0.196642 -0.094099
  7. 2001Q2 0.352070 -0.553642 0.196642 -0.094099
  8. 2001Q3 0.352070 -0.553642 0.196642 -0.094099
  9. 2001Q4 0.158207 0.042967 -0.360755 0.184687
  10.  
  11. In [537]: annual_frame.resample('Q-DEC', fill_method='ffill', convention='start')
  12. Out[537]:
  13. Colorado Texas New York Ohio
  14. 2000Q1 0.352070 -0.553642 0.196642 -0.094099
  15. 2000Q2 0.352070 -0.553642 0.196642 -0.094099
  16. 2000Q3 0.352070 -0.553642 0.196642 -0.094099
  17. 2000Q4 0.352070 -0.553642 0.196642 -0.094099
  18. 2001Q1 0.158207 0.042967 -0.360755 0.184687

由于时期指的是时间区间,所以升采样和降采样的规则就比较严格:

·在降采样中,目标频率必须是源频率的子时期(subperiod)。

·在升采样中,目标频率必须是源频率的超时期(superperiod)。

如果不满足这些条件,就会引发异常。这主要影响的是按季、年、周计算的频率。例如,由Q-MAR定义的时间区间只能升采样为A-MAR、A-JUN、A-SEP、A-DEC等:

  1. In [538]: annual_frame.resample('Q-MAR', fill_method='ffill')
  2. Out[538]:
  3. Colorado Texas New York Ohio
  4. 2001Q3 0.352070 -0.553642 0.196642 -0.094099
  5. 2001Q4 0.352070 -0.553642 0.196642 -0.094099
  6. 2002Q1 0.352070 -0.553642 0.196642 -0.094099
  7. 2002Q2 0.352070 -0.553642 0.196642 -0.094099
  8. 2002Q3 0.158207 0.042967 -0.360755 0.184687

注1:closed='right'、label='right'这两个默认值可能会让部分用户感到奇怪。在实际工作当中,这两个选项的值比较随意。对于某些目标频率,c l o s e d='l e f t'会更好,而对于其他的,则closed='right'才更为合理。你真正应该关注的是要如何对数据分段。