数据规整化方面的话题
前面几章中陆陆续续介绍过一些不错的数据规整化工具。这里,我将着重介绍一些跟金融问题域有关的话题。
时间序列以及截面对齐
在处理金融数据时,最费神的一个问题就是所谓的“数据对齐”(data alignment)问题。两个相关的时间序列的索引可能没有很好的对齐,或两个DataFrame对象可能含有不匹配的列或行。MATLAB、R以及其他矩阵编程语言的用户常常需要花费大量的精力将数据规整化为完全对齐的形式。以我的经验来看,手工处理数据对齐问题是一件令人非常郁闷的工作,而验证数据是否对齐则还要更郁闷一些。不仅如此,合并未对齐的数据还很有可能带来各种bug。
pandas可以在算术运算中自动对齐数据。在实际工作当中,这不仅能为你带来极大的自由度,而且还能提高你的工作效率。来看下面这两个DataFrame,它们分别含有股票价格和成交量的时间序列译注1:
- In [16]: prices
- Out[16]:
- AAPL JNJ SPX XOM
- 2011-09-06 379.74 64.64 1165.24 71.15
- 2011-09-07 383.93 65.43 1198.62 73.65
- 2011-09-08 384.14 64.95 1185.90 72.82
- 2011-09-09 377.48 63.64 1154.23 71.01
- 2011-09-12 379.94 63.59 1162.27 71.84
- 2011-09-13 384.62 63.61 1172.87 71.65
- 2011-09-14 389.30 63.73 1188.68 72.64
- In [17]: volume
- Out[17]:
- AAPL JNJ XOM
- 2011-09-06 18173500 15848300 25416300
- 2011-09-07 12492000 10759700 23108400
- 2011-09-08 14839800 15551500 22434800
- 2011-09-09 20171900 17008200 27969100
- 2011-09-12 16697300 13448200 26205800
假设你想要用所有有效数据计算一个成交量加权平均价格(为了简单起见,假设成交量数据是价格数据的子集)。由于pandas会在算术运算过程中自动将数据对齐,并在sum这样的函数中排除缺失数据,所以我们只需编写下面这条简洁的表达式即可:
- In [18]: prices * volume
- Out[18]:
- AAPL JNJ SPX XOM
- 2011-09-06 6901204890 1024434112 NaN 1808369745
- 2011-09-07 4796053560 704007171 NaN 1701933660
- 2011-09-08 5700560772 1010069925 NaN 1633702136
- 2011-09-09 7614488812 1082401848 NaN 1986085791
- 2011-09-12 6343972162 855171038 NaN 1882624672
- 2011-09-13 NaN NaN NaN NaN
- 2011-09-14 NaN NaN NaN NaN
- In [19]: vwap = (prices * volume).sum() / volume.sum()
- In [20]: vwap In [21]: vwap.dropna()
- Out[20]: Out[21]:
- AAPL 380.655181 AAPL 380.655181
- JNJ 64.394769 JNJ 64.394769
- SPX NaN XOM 72.024288
- XOM 72.024288
由于SPX在volume中找不到,所以你随时可以显式地将其丢弃。如果希望手工进行对齐,可以使用DataFrame的align方法,它返回的是一个元组,含有两个对象的重索引版本:
- In [22]: prices.align(volume, join='inner')
- Out[22]:
- ( AAPL JNJ XOM
- 2011-09-06 379.74 64.64 71.15
- 2011-09-07 383.93 65.43 73.65
- 2011-09-08 384.14 64.95 72.82
- 2011-09-09 377.48 63.64 71.01
- 2011-09-12 379.94 63.59 71.84,
- AAPL JNJ XOM
- 2011-09-06 18173500 15848300 25416300
- 2011-09-07 12492000 10759700 23108400
- 2011-09-08 14839800 15551500 22434800
- 2011-09-09 20171900 17008200 27969100
- 2011-09-12 16697300 13448200 26205800)
另一个不可或缺的功能是,通过一组索引可能不同的Series构建一个DataFrame。
- In [23]: s1 = Series(range(3), index=['a', 'b', 'c'])
- In [24]: s2 = Series(range(4), index=['d', 'b', 'c', 'e'])
- In [25]: s3 = Series(range(3), index=['f', 'a', 'c'])
- In [26]: DataFrame({'one': s1, 'two': s2, 'three': s3})
- Out[26]:
- one three two
- a 0 1 NaN
- b 1 NaN 1
- c 2 2 2
- d NaN NaN 0
- e NaN NaN 3
- f NaN 0 NaN
跟前面一样,这里也可以显式定义结果的索引(丢弃其余的数据):
- In [27]: DataFrame({'one': s1, 'two': s2, 'three': s3}, index=list('face'))
- Out[27]:
- one three two
- f NaN 0 NaN
- a 0 1 NaN
- c 2 2 2
- e NaN NaN 3
频率不同的时间序列的运算
经济学时间序列常常有着按年、季、月、日计算的或其他更特殊的频率。有些完全就是不规则的,比如说,盈利预测调整随时都可能会发生。频率转换和重对齐的两大主要工具是resample和reindex方法。resample用于将数据转换到固定频率,而reindex则用于使数据符合一个新索引。它们都支持插值(如前向填充)逻辑。
来看一个简单的周型时间序列:
- In [28]: ts1 = Series(np.random.randn(3),
- ...: index=pd.date_range('2012-6-13', periods=3, freq='W-WED'))
- In [29]: ts1
- Out[29]:
- 2012-06-13 -1.124801
- 2012-06-20 0.469004
- 2012-06-27 -0.117439
- Freq: W-WED
如果将其重采样到工作日(星期一到星期五)频率,则那些没有数据的日子就会出现一个“空洞”:
- In [30]: ts1.resample('B')
- Out[30]:
- 2012-06-13 -1.124801
- 2012-06-14 NaN
- 2012-06-15 NaN
- 2012-06-18 NaN
- 2012-06-19 NaN
- 2012-06-20 0.469004
- 2012-06-21 NaN
- 2012-06-22 NaN
- 2012-06-25 NaN
- 2012-06-26 NaN
- 2012-06-27 -0.117439
- Freq: B
当然,只需将fill_method设置为'ffill'即可用前面的值填充这些空白。处理较低频率的数据时常常这么干,因为最终结果中各时间点都有一个最新的有效值:
- In [31]: ts1.resample('B', fill_method='ffill')
- Out[31]:
- 2012-06-13 -1.124801
- 2012-06-14 -1.124801
- 2012-06-15 -1.124801
- 2012-06-18 -1.124801
- 2012-06-19 -1.124801
- 2012-06-20 0.469004
- 2012-06-21 0.469004
- 2012-06-22 0.469004
- 2012-06-25 0.469004
- 2012-06-26 0.469004
- 2012-06-27 -0.117439
- Freq: B
在实际工作当中,将较低频率的数据升采样到较高的规整频率是一种不错的解决方案,但是对于更一般化的不规整时间序列可能就不太合适了。看看下面这个不规整样本的时间序列(各时间点更一般化):
- In [32]: dates = pd.DatetimeIndex(['2012-6-12', '2012-6-17', '2012-6-18',
- ...: '2012-6-21', '2012-6-22', '2012-6-29'])
- In [33]: ts2 = Series(np.random.randn(6), index=dates)
- In [34]: ts2
- Out[34]:
- 2012-06-12 -0.449429
- 2012-06-17 0.459648
- 2012-06-18 -0.172531
- 2012-06-21 0.835938
- 2012-06-22 -0.594779
- 2012-06-29 0.027197
如果要将ts1中“最当前”的值(即前向填充)加到ts2上。一个办法是将两者重采样为规整频率后再相加,但是如果想维持ts2中的日期索引,则reindex会是一种更好的解决方案:
- In [35]: ts1.reindex(ts2.index, method='ffill')
- Out[35]:
- 2012-06-12 NaN
- 2012-06-17 -1.124801
- 2012-06-18 -1.124801
- 2012-06-21 0.469004
- 2012-06-22 0.469004
- 2012-06-29 -0.117439
- In [36]: ts2 + ts1.reindex(ts2.index, method='ffill')
- Out[36]:
- 2012-06-12 NaN
- 2012-06-17 -0.665153
- 2012-06-18 -1.297332
- 2012-06-21 1.304942
- 2012-06-22 -0.125775
- 2012-06-29 -0.090242
使用Period
Period(表示时间区间)提供了另一种处理不同频率时间序列的办法,尤其是那些有着特殊规范的以年或季度为频率的金融或经济序列。比如说,一个公司可能会发布其以6月结尾的财年的每季度盈利报告,即频率为Q-JUN。来看两个有关GDP和通货膨胀的宏观经济时间序列:
- In [37]: gdp = Series([1.78, 1.94, 2.08, 2.01, 2.15, 2.31, 2.46],
- ...: index=pd.period_range('1984Q2', periods=7, freq='Q-SEP'))
- In [38]: infl = Series([0.025, 0.045, 0.037, 0.04],
- ...: index=pd.period_range('1982', periods=4, freq='A-DEC'))
- In [39]: gdp In [40]: infl
- Out[39]: Out[40]:
- 1984Q2 1.78 1982 0.025
- 1984Q3 1.94 1983 0.045
- 1984Q4 2.08 1984 0.037
- 1985Q1 2.01 1985 0.040
- 1985Q2 2.15 Freq: A-DEC
- 1985Q3 2.31
- 1985Q4 2.46
- Freq: Q-SEP
跟Timestamp的时间序列不同,由Period索引的两个不同频率的时间序列之间的运算必须进行显式转换。在本例中,假设已知infl值是在每年年末观测的,于是我们就可以将其转换到Q-SEP以得到该频率下的正确时期:
- In [41]: infl_q = infl.asfreq('Q-SEP', how='end')
- In [42]: infl_q
- Out[42]:
- 1983Q1 0.025
- 1984Q1 0.045
- 1985Q1 0.037
- 1986Q1 0.040
- Freq: Q-SEP
然后这个时间序列就可以被重索引了(使用前向填充以匹配gdp):
- In [43]: infl_q.reindex(gdp.index, method='ffill')
- Out[43]:
- 1984Q2 0.045
- 1984Q3 0.045
- 1984Q4 0.045
- 1985Q1 0.037
- 1985Q2 0.037
- 1985Q3 0.037
- 1985Q4 0.037
- Freq: Q-SEP
时间和“最当前”数据选取
假设你有一个很长的盘中市场数据时间序列,现在希望抽取其中每天特定时间的价格数据。如果数据不规整(观测值没有精确地落在期望的时间点上),该怎么办?在实际工作当中,如果不够小心仔细的话,很容易导致错误的数据规整化。看看下面这个例子:
- # 生成一个交易日内的日期范围和时间序列译注2
- In [44]: rng = pd.date_range('2012-06-01 09:30', '2012-06-01 15:59', freq='T')
- # 生成5天的时间点(9:30~15:59之间的值)
- In [45]: rng = rng.append([rng + pd.offsets.BDay(i) for i in range(1, 4)])
- In [46]: ts = Series(np.arange(len(rng), dtype=float), index=rng)
- In [47]: ts
- Out[47]:
- 2012-06-01 09:30:00 0
- 2012-06-01 09:31:00 1
- 2012-06-01 09:32:00 2
- 2012-06-01 09:33:00 3
- ...
- 2012-06-06 15:56:00 1556
- 2012-06-06 15:57:00 1557
- 2012-06-06 15:58:00 1558
- 2012-06-06 15:59:00 1559
- Length: 1560
利用Python的datetime.time对象进行索引即可抽取出这些时间点上的值:
- In [48]: from datetime import time
- In [49]: ts[time(10, 0)]
- Out[49]:
- 2012-06-01 10:00:00 30
- 2012-06-04 10:00:00 420
- 2012-06-05 10:00:00 810
- 2012-06-06 10:00:00 1200
实际上,该操作用到了实例方法at_time(各时间序列以及类似的DataFrame对象都有):
- In [50]: ts.at_time(time(10, 0))
- Out[50]:
- 2012-06-01 10:00:00 30
- 2012-06-04 10:00:00 420
- 2012-06-05 10:00:00 810
- 2012-06-06 10:00:00 1200
还有一个between_time方法,它用于选取两个Time对象之间的值:
- In [51]: ts.between_time(time(10, 0), time(10, 1))
- Out[51]:
- 2012-06-01 10:00:00 30
- 2012-06-01 10:01:00 31
- 2012-06-04 10:00:00 420
- 2012-06-04 10:01:00 421
- 2012-06-05 10:00:00 810
- 2012-06-05 10:01:00 811
- 2012-06-06 10:00:00 1200
- 2012-06-06 10:01:00 1201
正如之前提到的那样,可能刚好就没有任何数据落在某个具体的时间上(比如上午10点)。这时,你可能会希望得到上午10点之前最后出现的那个值:
- # 将该时间序列的大部分内容随机设置为NA
- In [53]: indexer = np.sort(np.random.permutation(len(ts))[700:])
- In [54]: irr_ts = ts.copy()
- In [55]: irr_ts[indexer] = np.nan
- In [56]: irr_ts['2012-06-01 09:50':'2012-06-01 10:00']
- Out[56]:
- 2012-06-01 09:50:00 NaN
- 2012-06-01 09:51:00 NaN
- 2012-06-01 09:52:00 22
- 2012-06-01 09:53:00 NaN
- 2012-06-01 09:54:00 24
- 2012-06-01 09:55:00 NaN
- 2012-06-01 09:56:00 26
- 2012-06-01 09:57:00 27
- 2012-06-01 09:58:00 28
- 2012-06-01 09:59:00 29
- 2012-06-01 10:00:00 NaN
如果将一组Timestamp传入asof方法,就能得到这些时间点处(或其之前最近)的有效值(非NA)。例如,我们构造一个日期范围(每天上午10点),然后将其传入asof:
- In [57]: selection = pd.date_range('2012-06-01 10:00', periods=4, freq='B')
- In [58]: irr_ts.asof(selection)
- Out[58]:
- 2012-06-01 10:00:00 29
- 2012-06-04 10:00:00 419
- 2012-06-05 10:00:00 810
- 2012-06-06 10:00:00 1198
- Freq: B
拼接多个数据源
在第7章中,我介绍了一些合并两个相关数据集的办法。在金融或经济领域中,还有另外几个经常出现的情况:
·在一个特定的时间点上,从一个数据源切换到另一个数据源。
·用另一个时间序列对当前时间序列中的缺失值“打补丁”。
·将数据中的符号(国家、资产代码等)替换为实际数据。
对于第一种情况,在特定时刻从一个时间序列切换到另一个,其实就是用pandas.concat将两个TimeSeries或DataFrame对象合并到一起:
- In [59]: data1 = DataFrame(np.ones((6, 3), dtype=float),
- ...: columns=['a', 'b', 'c'],
- ...: index=pd.date_range('6/12/2012', periods=6))
- In [60]: data2 = DataFrame(np.ones((6, 3), dtype=float) * 2,
- ...: columns=['a', 'b', 'c'],
- ...: index=pd.date_range('6/13/2012', periods=6))
- In [61]: spliced = pd.concat([data1.ix[:'2012-06-14'], data2.ix['2012-06-15':]])
- In [62]: spliced
- Out[62]:
- a b c
- 2012-06-12 1 1 1
- 2012-06-13 1 1 1
- 2012-06-14 1 1 1
- 2012-06-15 2 2 2
- 2012-06-16 2 2 2
- 2012-06-17 2 2 2
- 2012-06-18 2 2 2
再看另一个简单的例子,假设data1缺失了data2中存在的某个时间序列:
- In [113]: data2 = DataFrame(np.ones((6, 4), dtype=float) * 2,
- ...: columns=['a', 'b', 'c', 'd'],
- ...: index=pd.date_range('6/13/2012', periods=6))
- In [64]: spliced = pd.concat([data1.ix[:'2012-06-14'], data2.ix['2012-06-15':]])
- In [65]: spliced
- Out[65]:
- a b c d
- 2012-06-12 1 1 1 NaN
- 2012-06-13 1 1 1 NaN
- 2012-06-14 1 1 1 NaN
- 2012-06-15 2 2 2 2
- 2012-06-16 2 2 2 2
- 2012-06-17 2 2 2 2
- 2012-06-18 2 2 2 2
combine_first可以引入合并点之前的数据,这样也就扩展了'd'项的历史:
- In [66]: spliced_filled = spliced.combine_first(data2)
- In [67]: spliced_filled
- Out[67]:
- a b c d
- 2012-06-12 1 1 1 NaN
- 2012-06-13 1 1 1 2
- 2012-06-14 1 1 1 2
- 2012-06-15 2 2 2 2
- 2012-06-16 2 2 2 2
- 2012-06-17 2 2 2 2
- 2012-06-18 2 2 2 2
由于data2没有关于2012-06-12的数据,所以也就没有值被填充到那一天。
DataFrame也有一个类似的方法update,它可以实现就地更新。如果只想填充空洞,则必须传入overwrite=False才行:
- In [68]: spliced.update(data2, overwrite=False)
- In [69]: spliced
- Out[69]:
- a b c d
- 2012-06-12 1 1 1 NaN
- 2012-06-13 1 1 1 2
- 2012-06-14 1 1 1 2
- 2012-06-15 2 2 2 2
- 2012-06-16 2 2 2 2
- 2012-06-17 2 2 2 2
- 2012-06-18 2 2 2 2
上面所讲的这些技术都可实现将数据中的符号替换为实际数据,但有时利用DataFrame的索引机制直接对列进行设置会更简单一些:
- In [70]: cp_spliced = spliced.copy()
- In [71]: cp_spliced[['a', 'c']] = data1[['a', 'c']]
- In [72]: cp_spliced
- Out[72]:
- a b c d
- 2012-06-12 1 1 1 NaN
- 2012-06-13 1 1 1 2
- 2012-06-14 1 1 1 2
- 2012-06-15 1 2 1 2
- 2012-06-16 1 2 1 2
- 2012-06-17 1 2 1 2
- 2012-06-18 NaN 2 NaN 2
收益指数和累计收益
在金融领域中,收益(return)通常指的是某资产价格的百分比变化。我们来看看2011年到2012年间苹果公司的股票价格数据译注3:
- In [73]: import pandas.io.data as web
- In [74]: price = web.get_data_yahoo('AAPL', '2011-01-01')['Adj Close']
- In [75]: price[-5:]
- Out[75]:
- Date
- 2012-07-23 603.83
- 2012-07-24 600.92
- 2012-07-25 574.97
- 2012-07-26 574.88
- 2012-07-27 585.16
- Name: Adj Close
对于苹果公司的股票(没有股息译注4),计算两个时间点之间的累计百分比回报只需计算价格的百分比变化即可:
- In [76]: price['2011-10-03'] / price['2011-3-01'] - 1
- Out[76]: 0.072399874037388123
对于其他那些派发股息的股票,要计算你在某只股票上赚了多少钱就比较复杂了。不过,这里所使用的已调整收盘价已经对拆分和股息做出了调整。不管什么样的情况,通常都会先算出一个收益指数,它是一个表示单位投资(比如1美元)收益的时间序列。从收益指数中可以得出许多假设。例如,人们可以决定是否进行利润再投资。对于苹果公司的情况,我们可以利用cumprod计算出一个简单的收益指数:
- In [77]: returns = price.pct_change()
- In [78]: ret_index = (1 + returns).cumprod()
- In [79]: ret_index[0] = 1 # 将第一个值设置为1
- In [80]: ret_index
- Out[80]:
- Date
- 2011-01-03 1.000000
- 2011-01-04 1.005219
- 2011-01-05 1.013442
- 2011-01-06 1.012623
- ...
- 2012-07-24 1.823346
- 2012-07-25 1.744607
- 2012-07-26 1.744334
- 2012-07-27 1.775526
- Length: 396
得到收益指数之后,计算指定时期内的累计收益就很简单了:
- In [81]: m_returns = ret_index.resample('BM', how='last').pct_change()
- In [82]: m_returns['2012']
- Out[82]:
- Date
- 2012-01-31 0.127111
- 2012-02-29 0.188311
- 2012-03-30 0.105284
- 2012-04-30 -0.025969
- 2012-05-31 -0.010702
- 2012-06-29 0.010853
- 2012-07-31 0.001986
- Freq: BM
当然了,就这个简单的例子而言(没有股息也没有其他需要考虑的调整),上面的结果也能通过重采样聚合(这里聚合为时期)从日百分比变化中计算得出:
- In [83]: m_rets = (1 + returns).resample('M', how='prod', kind='period') - 1
- In [84]: m_rets['2012']
- Out[84]:
- Date
- 2012-01 0.127111
- 2012-02 0.188311
- 2012-03 0.105284
- 2012-04 -0.025969
- 2012-05 -0.010702
- 2012-06 0.010853
- 2012-07 0.001986
- Freq: M
如果知道了股息的派发日和支付率,就可以将它们计入到每日总收益中,如下所示:
- returns[dividend_dates] += dividend_pcts
译注1:此处代码不完整,需要加载ch11的两个csv文件,然后稍作处理即可得到这里所需的素材。
译注2:这里生成的只是索引,没有时间序列。
译注3:直接使用这段代码获取的数据会多很多,因为没有截止日期,建议使用price=web.get_data_yahoo('AAPL','2011-01-01','2012-07-27')['Adj Close']。此外,由于这里获取的是Adj Close,所以数据本身也会有一些不同。
译注4:现在已经派过股息了。