分组级运算和转换

聚合只不过是分组运算的其中一种而已。它是数据转换的一个特例,也就是说,它接受能够将一维数组简化为标量值的函数。在本节中,我将介绍transform和apply方法,它们能够执行更多其他的分组运算。

假设我们想要为一个DataFrame添加一个用于存放各索引分组平均值的列。一个办法是先聚合再合并:

  1. In [77]: df
  2. Out[77]:
  3. data1 data2 key1 key2
  4. 0 -0.204708 1.393406 a one
  5. 1 0.478943 0.092908 a two
  6. 2 -0.519439 0.281746 b one
  7. 3 -0.555730 0.769023 b two
  8. 4 1.965781 1.246435 a one
  9.  
  10. In [78]: k1_means = df.groupby('key1').mean().add_prefix('mean_')
  11.  
  12. In [79]: k1_means
  13. Out[79]:
  14. mean_data1 mean_data2
  15. key1
  16. a 0.746672 0.910916
  17. b -0.537585 0.525384
  18.  
  19. In [80]: pd.merge(df, k1_means, left_on='key1', right_index=True)
  20. Out[80]:
  21. data1 data2 key1 key2 mean_data1 mean_data2
  22. 0 -0.204708 1.393406 a one 0.746672 0.910916
  23. 1 0.478943 0.092908 a two 0.746672 0.910916
  24. 4 1.965781 1.246435 a one 0.746672 0.910916
  25. 2 -0.519439 0.281746 b one -0.537585 0.525384
  26. 3 -0.555730 0.769023 b two -0.537585 0.525384

虽然这样也行,但是不太灵活。你可以将该过程看做利用np.mean函数对两个数据列进行转换。再以本章前面用过的那个people DataFrame为例,这次我们在GroupBy上使用transform方法:

  1. In [81]: key = ['one', 'two', 'one', 'two', 'one']
  2.  
  3. In [82]: people.groupby(key).mean()
  4. Out[82]:
  5. a b c d e
  6. one -0.082032 -1.063687 -1.047620 -0.884358 -0.028309
  7. two 0.505275 -0.849512 0.075965 0.834983 0.452620
  8.  
  9. In [83]: people.groupby(key).transform(np.mean)
  10. Out[83]:
  11. a b c d e
  12. Joe -0.082032 -1.063687 -1.047620 -0.884358 -0.028309
  13. Steve 0.505275 -0.849512 0.075965 0.834983 0.452620
  14. Wes -0.082032 -1.063687 -1.047620 -0.884358 -0.028309
  15. Jim 0.505275 -0.849512 0.075965 0.834983 0.452620
  16. Travis -0.082032 -1.063687 -1.047620 -0.884358 -0.028309

不难看出,transform会将一个函数应用到各个分组,然后将结果放置到适当的位置上。如果各分组产生的是一个标量值,则该值就会被广播出去。现在,假设你希望从各组中减去平均值。为此,我们先创建一个距平化函数(demeaning function),然后将其传给transform:

  1. In [84]: def demean(arr):
  2. ...: return arr - arr.mean()
  3.  
  4. In [85]: demeaned = people.groupby(key).transform(demean)
  5.  
  6. In [86]: demeaned
  7. Out[86]:
  8. a b c d e
  9. Joe 1.089221 -0.232534 1.322612 1.113271 1.381226
  10. Steve 0.381154 -1.152125 -0.447807 0.834043 -0.891190
  11. Wes -0.457709 NaN NaN -0.136869 -0.548778
  12. Jim -0.381154 1.152125 0.447807 -0.834043 0.891190
  13. Travis -0.631512 0.232534 -1.322612 -0.976402 -0.832448

你可以检查一下demeaned现在的分组平均值是否为0:

  1. In [87]: demeaned.groupby(key).mean()
  2. Out[87]:
  3.    a b c d e
  4. one 0 -0 0 0 0
  5. two -0 0 0 0 0

在下一节中你将会看到,分组距平化操作还可以通过apply实现。

apply:一般性的“拆分-应用-合并”

跟aggregate一样,transform也是一个有着严格条件的特殊函数:传入的函数只能产生两种结果,要么产生一个可以广播的标量值(如np.mean),要么产生一个相同大小的结果数组。最一般化的GroupBy方法是apply,本节剩余部分将重点讲解它。如图9-1所示,apply会将待处理的对象拆分成多个片段,然后对各片段调用传入的函数,最后尝试将各片段组合到一起。

回到之前那个小费数据集,假设你想要根据分组选出最高的5个tip_pct值。首先,编写一个选取指定列具有最大值的行的函数译注5

  1. In [88]: def top(df, n=5, column='tip_pct'):
  2. ...: return df.sort_index(by=column)[-n:]
  3.  
  4. In [89]: top(tips, n=6)
  5. Out[89]:
  6. total_bill tip sex smoker day time size tip_pct
  7. 109 14.31 4.00 Female True Sat Dinner 2 0.279525
  8. 183 23.17 6.50 Male True Sun Dinner 4 0.280535
  9. 232 11.61 3.39 Male False Sat Dinner 2 0.291990
  10. 67 3.07 1.00 Female True Sat Dinner 1 0.325733
  11. 178 9.60 4.00 Female True Sun Dinner 2 0.416667
  12. 172 7.25 5.15 Male True Sun Dinner 2 0.710345

现在,如果对smoker分组并用该函数调用apply,就会得到:

  1. In [90]: tips.groupby('smoker').apply(top)
  2. Out[90]:
  3. total_bill tip sex smoker day time size tip_pct
  4. smoker
  5. No 88 24.71 5.85 Male False Thur Lunch 2 0.236746
  6. 185 20.69 5.00 Male False Sun Dinner 5 0.241663
  7. 51 10.29 2.60 Female False Sun Dinner 2 0.252672
  8. 149 7.51 2.00 Male False Thur Lunch 2 0.266312
  9. 232 11.61 3.39 Male False Sat Dinner 2 0.291990
  10. Yes 109 14.31 4.00 Female True Sat Dinner 2 0.279525
  11. 183 23.17 6.50 Male True Sun Dinner 4 0.280535
  12. 67 3.07 1.00 Female True Sat Dinner 1 0.325733
  13. 178 9.60 4.00 Female True Sun Dinner 2 0.416667
  14. 172 7.25 5.15 Male True Sun Dinner 2 0.710345

这里发生了什么?top函数在DataFrame的各个片段上调用,然后结果由pandas.concat组装到一起,并以分组名称进行了标记。于是,最终结果就有了一个层次化索引,其内层索引值来自原DataFrame。

如果传给apply的函数能够接受其他参数或关键字,则可以将这些内容放在函数名后面一并传入:

  1. In [91]: tips.groupby(['smoker', 'day']).apply(top, n=1, column='total_bill')
  2. Out[91]:
  3. total_bill tip sex smoker day time size tip_pct
  4. smoker day
  5. No Fri 94 22.75 3.25 Female False Fri Dinner 2 0.142857
  6. Sat 212 48.33 9.00 Male False Sat Dinner 4 0.186220
  7. Sun 156 48.17 5.00 Male False Sun Dinner 6 0.103799
  8. Thur 142 41.19 5.00 Male False Thur Lunch 5 0.121389
  9. Yes Fri 95 40.17 4.73 Male True Fri Dinner 4 0.117750
  10. Sat 170 50.81 10.00 Male True Sat Dinner 3 0.196812
  11. Sun 182 45.35 3.50 Male True Sun Dinner 3 0.077178
  12. Thur 197 43.11 5.00 Female True Thur Lunch 4 0.115982

注意: 除这些基本用法之外,能否充分发挥apply的威力很大程度上取决于你的创造力。传入的那个函数能做什么全由你说了算,它只需返回一个pandas对象或标量值即可。本章后续部分的示例主要用于讲解如何利用groupby解决各种各样的问题。

可能你已经想起来了,之前我在GroupBy对象上调用过describe:

  1. In [92]: result = tips.groupby('smoker')['tip_pct'].describe()
  2.  
  3. In [93]: result
  4. Out[93]:
  5. smoker
  6. No count 151.000000
  7. mean 0.159328
  8. std 0.039910
  9. min 0.056797
  10. 25% 0.136906
  11. 50% 0.155625
  12. 75% 0.185014
  13. max 0.291990
  14. Yes count 93.000000
  15. mean 0.163196
  16. std 0.085119
  17. min 0.035638
  18. 25% 0.106771
  19. 50% 0.153846
  20. 75% 0.195059
  21. max 0.710345
  22.  
  23. In [94]: result.unstack('smoker')
  24. Out[94]:
  25. smoker No Yes
  26. count 151.000000 93.000000
  27. mean 0.159328 0.163196
  28. std 0.039910 0.085119
  29. min 0.056797 0.035638
  30. 25% 0.136906 0.106771
  31. 50% 0.155625 0.153846
  32. 75% 0.185014 0.195059
  33. max 0.291990 0.710345

在GroupBy中,当你调用诸如describe之类的方法时,实际上只是应用了下面两条代码的快捷方式而已:

  1. f = lambda x: x.describe()
  2. grouped.apply(f)

禁止分组键

从上面的例子中可以看出,分组键会跟原始对象的索引共同构成结果对象中的层次化索引。将group_keys=False传入groupby即可禁止该效果:

  1. In [95]: tips.groupby('smoker', group_keys=False).apply(top)
  2. Out[95]:
  3. total_bill tip sex smoker day time size tip_pct
  4. 88 24.71 5.85 Male False Thur Lunch 2 0.236746
  5. 185 20.69 5.00 Male False Sun Dinner 5 0.241663
  6. 51 10.29 2.60 Female False Sun Dinner 2 0.252672
  7. 149 7.51 2.00 Male False Thur Lunch 2 0.266312
  8. 232 11.61 3.39 Male False Sat Dinner 2 0.291990
  9. 109 14.31 4.00 Female True Sat Dinner 2 0.279525
  10. 183 23.17 6.50 Male True Sun Dinner 4 0.280535
  11. 67 3.07 1.00 Female True Sat Dinner 1 0.325733
  12. 178 9.60 4.00 Female True Sun Dinner 2 0.416667
  13. 172 7.25 5.15 Male True Sun Dinner 2 0.710345

分位数和桶分析

我曾在第7章中讲过,pandas有一些能根据指定面元或样本分位数将数据拆分成多块的工具(比如cut和qcut)。将这些函数跟groupby结合起来,就能非常轻松地实现对数据集的桶(bucket)或分位数(quantile)分析了。以下面这个简单的随机数据集为例,我们利用cut将其装入长度相等的桶中:

  1. In [96]: frame = DataFrame({'data1': np.random.randn(1000),
  2. ...: 'data2': np.random.randn(1000)})
  3.  
  4. In [97]: factor = pd.cut(frame.data1, 4)
  5.  
  6. In [98]: factor[:10]
  7. Out[98]:
  8. Categorical:
  9. array([(-1.23, 0.489], (-2.956, -1.23], (-1.23, 0.489], (0.489, 2.208],
  10.     (-1.23, 0.489], (0.489, 2.208], (-1.23, 0.489], (-1.23, 0.489],
  11.     (0.489, 2.208], (0.489, 2.208]], dtype=object)
  12. Levels (4): Index([(-2.956, -1.23], (-1.23, 0.489], (0.489, 2.208],
  13. (2.208, 3.928]], dtype=object)

由cut返回的Factor对象可直接用于groupby。因此,我们可以像下面这样对data2做一些统计计算:

  1. In [99]: def get_stats(group):
  2. ...: return {'min': group.min(), 'max': group.max(),
  3. ...: 'count': group.count(), 'mean': group.mean()}
  4.  
  5. In [100]: grouped = frame.data2.groupby(factor)
  6.  
  7. In [101]: grouped.apply(get_stats).unstack()
  8. Out[101]:
  9. count max mean min
  10. data1
  11. (-1.23, 0.489] 598 3.260383 -0.002051 -2.989741
  12. (-2.956, -1.23] 95 1.670835 -0.039521 -3.399312
  13. (0.489, 2.208] 297 2.954439 0.081822 -3.745356
  14. (2.208, 3.928] 10 1.765640 0.024750 -1.929776

这些都是长度相等的桶。要根据样本分位数得到大小相等的桶,使用qcut即可译注6。传入labels=False即可只获取分位数的编号。

  1. # 返回分位数编号
  2. In [102]: grouping = pd.qcut(frame.data1, 10, labels=False)
  3.  
  4. In [103]: grouped = frame.data2.groupby(grouping)
  5.  
  6. In [104]: grouped.apply(get_stats).unstack()
  7. Out[104]:
  8. count max mean min
  9. 0 100 1.670835 -0.049902 -3.399312
  10. 1 100 2.628441 0.030989 -1.950098
  11. 2 100 2.527939 -0.067179 -2.925113
  12. 3 100 3.260383 0.065713 -2.315555
  13. 4 100 2.074345 -0.111653 -2.047939
  14. 5 100 2.184810 0.052130 -2.989741
  15. 6 100 2.458842 -0.021489 -2.223506
  16. 7 100 2.954439 -0.026459 -3.056990
  17. 8 100 2.735527 0.103406 -3.745356
  18. 9 100 2.377020 0.220122 -2.064111

示例:用特定于分组的值填充缺失值

对于缺失数据的清理工作,有时你会用dropna将其滤除,而有时则可能会希望用一个固定值或由数据集本身所衍生出来的值去填充NA值。这时就得使用fillna这个工具了。在下面这个例子中,我用平均值去填充NA值:

  1. In [105]: s = Series(np.random.randn(6))
  2.  
  3. In [106]: s[::2] = np.nan
  4.  
  5. In [107]: s
  6. Out[107]:
  7. 0 NaN
  8. 1 -0.125921
  9. 2 NaN
  10. 3 -0.884475
  11. 4 NaN
  12. 5 0.227290
  13.  
  14. In [108]: s.fillna(s.mean())
  15. Out[108]:
  16. 0 -0.261035
  17. 1 -0.125921
  18. 2 -0.261035
  19. 3 -0.884475
  20. 4 -0.261035
  21. 5 0.227290

假设你需要对不同的分组填充不同的值。可能你已经猜到了,只需将数据分组,并使用apply和一个能够对各数据块调用fillna的函数即可。下面是一些有关美国几个州的示例数据,这些州又被分为东部和西部:

  1. In [109]: states = ['Ohio', 'New York', 'Vermont', 'Florida',
  2. ...: 'Oregon', 'Nevada', 'California', 'Idaho']
  3.  
  4. In [110]: group_key = ['East'] * 4 + ['West'] * 4
  5.  
  6. In [111]: data = Series(np.random.randn(8), index=states)
  7.  
  8. In [112]: data[['Vermont', 'Nevada', 'Idaho']] = np.nan
  9.  
  10. In [113]: data
  11. Out[113]:
  12. Ohio 0.922264
  13. New York -2.153545
  14. Vermont NaN
  15. Florida -0.375842
  16. Oregon 0.329939
  17. Nevada NaN
  18. California 1.105913
  19. Idaho NaN
  20.  
  21. In [114]: data.groupby(group_key).mean()
  22. Out[114]:
  23. East -0.535707
  24. West 0.717926

我们可以用分组平均值去填充NA值,如下所示:

  1. In [115]: fill_mean = lambda g: g.fillna(g.mean())
  2.  
  3. In [116]: data.groupby(group_key).apply(fill_mean)
  4. Out[116]:
  5. Ohio 0.922264
  6. New York -2.153545
  7. Vermont -0.535707
  8. Florida -0.375842
  9. Oregon 0.329939
  10. Nevada 0.717926
  11. California 1.105913
  12. Idaho 0.717926

此外,也可以在代码中预定义各组的填充值。由于分组具有一个name属性,所以我们可以拿来用一下:

  1. In [117]: fill_values = {'East': 0.5, 'West': -1}
  2.  
  3. In [118]: fill_func = lambda g: g.fillna(fill_values[g.name])
  4.  
  5. In [119]: data.groupby(group_key).apply(fill_func)
  6. Out[119]:
  7. Ohio 0.922264
  8. New York -2.153545
  9. Vermont 0.500000
  10. Florida -0.375842
  11. Oregon 0.329939
  12. Nevada -1.000000
  13. California 1.105913
  14. Idaho -1.000000

示例:随机采样和排列

假设你想要从一个大数据集中随机抽取样本以进行蒙特卡罗模拟(Monte Carlo simulation)或其他分析工作。“抽取”的方式有很多,其中一些的效率会比其他的高很多。一个办法是,选取np.random.permutation(N)的前K个元素,其中N为完整数据的大小,K为期望的样本大小。作为一个更有趣的例子,下面是构造一副英语型扑克牌的一个方式:

  1. # 红桃(Hearts)、黑桃(Spades)、梅花(Clubs)、方片(Diamonds)
  2. suits = ['H', 'S', 'C', 'D']
  3. card_val = (range(1, 11) + [10] * 3) * 4
  4. base_names = ['A'] + range(2, 11) + ['J', 'K', 'Q']
  5. cards = []
  6. for suit in ['H', 'S', 'C', 'D']:
  7. cards.extend(str(num) + suit for num in base_names)
  8.  
  9. deck = Series(card_val, index=cards)

现在我有了一个长度为52的Series,其索引为牌名,值则是21点或其他游戏中用于计分的点数(为了简单起见,我当A的点数为1):

  1. In [121]: deck[:13]
  2. Out[121]:
  3. AH 1
  4. 2H 2
  5. 3H 3
  6. 4H 4
  7. 5H 5
  8. 6H 6
  9. 7H 7
  10. 8H 8
  11. 9H 9
  12. 10H 10
  13. JH 10
  14. KH 10
  15. QH 10

现在,根据我上面所讲的,从整副牌中抽出5张,代码如下:

  1. In [122]: def draw(deck, n=5):
  2. ...: return deck.take(np.random.permutation(len(deck))[:n])
  3.  
  4. In [123]: draw(deck)
  5. Out[123]:
  6. AD 1
  7. 8C 8
  8. 5H 5
  9. KC 10
  10. 2C 2

假设你想要从每种花色中随机抽取两张牌。由于花色是牌名的最后一个字符,所以我们可以据此进行分组,并使用apply:

  1. In [124]: get_suit = lambda card: card[-1] # 只要最后一个字母就可以了
  2.  
  3. In [125]: deck.groupby(get_suit).apply(draw, n=2)
  4. Out[125]:
  5. C 2C 2
  6. 3C 3
  7. D KD 10
  8. 8D 8
  9. H KH 10
  10. 3H 3
  11. S 2S 2
  12. 4S 4
  13.  
  14. # 另一种办法
  15. In [126]: deck.groupby(get_suit, group_keys=False).apply(draw, n=2)
  16. Out[126]:
  17. KC 10
  18. JC 10
  19. AD 1
  20. 5D 5
  21. 5H 5
  22. 6H 6
  23. 7S 7
  24. KS 10

示例:分组加权平均数和相关系数

根据groupby的“拆分-应用-合并”范式,DataFrame的列与列之间或两个Series之间的运算(比如分组加权平均)成为一种标准作业。以下面这个数据集为例,它含有分组键、值以及一些权重值:

  1. In [127]: df = DataFrame({'category': ['a', 'a', 'a', 'a', 'b', 'b', 'b', 'b'],
  2. ...: 'data': np.random.randn(8),
  3. ...: 'weights': np.random.rand(8)})
  4.  
  5. In [128]: df
  6. Out[128]:
  7. category data weights
  8. 0 a 1.561587 0.957515
  9. 1 a 1.219984 0.347267
  10. 2 a -0.482239 0.581362
  11. 3 a 0.315667 0.217091
  12. 4 b -0.047852 0.894406
  13. 5 b -0.454145 0.918564
  14. 6 b -0.556774 0.277825
  15. 7 b 0.253321 0.955905

然后可以利用category计算分组加权平均数:

  1. In [129]: grouped = df.groupby('category')
  2.  
  3. In [130]: get_wavg = lambda g: np.average(g['data'], weights=g['weights'])
  4.  
  5. In [131]: grouped.apply(get_wavg)
  6. Out[131]:
  7. category
  8. a 0.811643
  9. b -0.122262

这个例子比较无聊,所以再看一个稍微实际点的例子——来自Yahoo!Finance的数据集,其中含有标准普尔500指数(SPX字段)和几只股票的收盘价:

  1. In [132]: close_px = pd.read_csv('ch09/stock_px.csv', parse_dates=True, index_col=0)
  2.  
  3. In [133]: close_px
  4. Out[133]:
  5. <class 'pandas.core.frame.DataFrame'>
  6. DatetimeIndex: 2214 entries, 2003-01-02 00:00:00 to 2011-10-14 00:00:00
  7. Data columns:
  8. AAPL 2214 non-null values
  9. MSFT 2214 non-null values
  10. XOM 2214 non-null values
  11. SPX 2214 non-null values
  12. dtypes: float64(4)
  13.  
  14. In [134]: close_px[-4:]
  15. Out[134]:
  16. AAPL MSFT XOM SPX
  17. 2011-10-11 400.29 27.00 76.27 1195.54
  18. 2011-10-12 402.19 26.96 77.16 1207.25
  19. 2011-10-13 408.43 27.18 76.37 1203.66
  20. 2011-10-14 422.00 27.27 78.11 1224.58

来做一个比较有趣的任务:计算一个由日收益率(通过百分数变化计算)与SPX之间的年度相关系数组成的DataFrame。下面是一个实现办法:

  1. In [135]: rets = close_px.pct_change().dropna()
  2.  
  3. In [136]: spx_corr = lambda x: x.corrwith(x['SPX'])
  4.  
  5. In [137]: by_year = rets.groupby(lambda x: x.year)
  6.  
  7. In [138]: by_year.apply(spx_corr)
  8. Out[138]:
  9. AAPL MSFT XOM SPX
  10. 2003 0.541124 0.745174 0.661265 1
  11. 2004 0.374283 0.588531 0.557742 1
  12. 2005 0.467540 0.562374 0.631010 1
  13. 2006 0.428267 0.406126 0.518514 1
  14. 2007 0.508118 0.658770 0.786264 1
  15. 2008 0.681434 0.804626 0.828303 1
  16. 2009 0.707103 0.654902 0.797921 1
  17. 2010 0.710105 0.730118 0.839057 1
  18. 2011 0.691931 0.800996 0.859975 1

当然,你还可以计算列与列之间的相关系数:

  1. # 苹果和微软的年度相关系数
  2. In [139]: by_year.apply(lambda g: g['AAPL'].corr(g['MSFT']))
  3. Out[139]:
  4. 2003 0.480868
  5. 2004 0.259024
  6. 2005 0.300093
  7. 2006 0.161735
  8. 2007 0.417738
  9. 2008 0.611901
  10. 2009 0.432738
  11. 2010 0.571946
  12. 2011 0.581987

示例:面向分组的线性回归

顺着上一个例子继续,你可以用groupby执行更为复杂的分组统计分析,只要函数返回的是pandas对象或标量值即可。例如,我可以定义下面这个regress函数(利用statsmodels库)对各数据块执行普通最小二乘法(Ordinary Least Squares,OLS)回归:

  1. import statsmodels.api as sm
  2. def regress(data, yvar, xvars):
  3. Y = data[yvar]
  4. X = data[xvars]
  5. X['intercept'] = 1.
  6. result = sm.OLS(Y, X).fit()
  7. return result.params

现在,为了按年计算AAPL对SPX收益率的线性回归,我执行:

  1. In [141]: by_year.apply(regress, 'AAPL', ['SPX'])
  2. Out[141]:
  3. SPX intercept
  4. 2003 1.195406 0.000710
  5. 2004 1.363463 0.004201
  6. 2005 1.766415 0.003246
  7. 2006 1.645496 0.000080
  8. 2007 1.198761 0.003438
  9. 2008 0.968016 -0.001110
  10. 2009 0.879103 0.002954
  11. 2010 1.052608 0.001261
  12. 2011 0.806605 0.001514

译注5:原文比较拗口,其实就是“在指定列找出最大值,然后把这个值所在的行选取出来”。

译注6:补充说明一下。“长度相等的桶”指的是“区间大小相等”,“大小相等的桶”指的是“数据点数量相等”。