分组级运算和转换
聚合只不过是分组运算的其中一种而已。它是数据转换的一个特例,也就是说,它接受能够将一维数组简化为标量值的函数。在本节中,我将介绍transform和apply方法,它们能够执行更多其他的分组运算。
假设我们想要为一个DataFrame添加一个用于存放各索引分组平均值的列。一个办法是先聚合再合并:
- In [77]: df
- Out[77]:
- data1 data2 key1 key2
- 0 -0.204708 1.393406 a one
- 1 0.478943 0.092908 a two
- 2 -0.519439 0.281746 b one
- 3 -0.555730 0.769023 b two
- 4 1.965781 1.246435 a one
- In [78]: k1_means = df.groupby('key1').mean().add_prefix('mean_')
- In [79]: k1_means
- Out[79]:
- mean_data1 mean_data2
- key1
- a 0.746672 0.910916
- b -0.537585 0.525384
- In [80]: pd.merge(df, k1_means, left_on='key1', right_index=True)
- Out[80]:
- data1 data2 key1 key2 mean_data1 mean_data2
- 0 -0.204708 1.393406 a one 0.746672 0.910916
- 1 0.478943 0.092908 a two 0.746672 0.910916
- 4 1.965781 1.246435 a one 0.746672 0.910916
- 2 -0.519439 0.281746 b one -0.537585 0.525384
- 3 -0.555730 0.769023 b two -0.537585 0.525384
虽然这样也行,但是不太灵活。你可以将该过程看做利用np.mean函数对两个数据列进行转换。再以本章前面用过的那个people DataFrame为例,这次我们在GroupBy上使用transform方法:
- In [81]: key = ['one', 'two', 'one', 'two', 'one']
- In [82]: people.groupby(key).mean()
- Out[82]:
- a b c d e
- one -0.082032 -1.063687 -1.047620 -0.884358 -0.028309
- two 0.505275 -0.849512 0.075965 0.834983 0.452620
- In [83]: people.groupby(key).transform(np.mean)
- Out[83]:
- a b c d e
- Joe -0.082032 -1.063687 -1.047620 -0.884358 -0.028309
- Steve 0.505275 -0.849512 0.075965 0.834983 0.452620
- Wes -0.082032 -1.063687 -1.047620 -0.884358 -0.028309
- Jim 0.505275 -0.849512 0.075965 0.834983 0.452620
- Travis -0.082032 -1.063687 -1.047620 -0.884358 -0.028309
不难看出,transform会将一个函数应用到各个分组,然后将结果放置到适当的位置上。如果各分组产生的是一个标量值,则该值就会被广播出去。现在,假设你希望从各组中减去平均值。为此,我们先创建一个距平化函数(demeaning function),然后将其传给transform:
- In [84]: def demean(arr):
- ...: return arr - arr.mean()
- In [85]: demeaned = people.groupby(key).transform(demean)
- In [86]: demeaned
- Out[86]:
- a b c d e
- Joe 1.089221 -0.232534 1.322612 1.113271 1.381226
- Steve 0.381154 -1.152125 -0.447807 0.834043 -0.891190
- Wes -0.457709 NaN NaN -0.136869 -0.548778
- Jim -0.381154 1.152125 0.447807 -0.834043 0.891190
- Travis -0.631512 0.232534 -1.322612 -0.976402 -0.832448
你可以检查一下demeaned现在的分组平均值是否为0:
- In [87]: demeaned.groupby(key).mean()
- Out[87]:
- a b c d e
- one 0 -0 0 0 0
- two -0 0 0 0 0
在下一节中你将会看到,分组距平化操作还可以通过apply实现。
apply:一般性的“拆分-应用-合并”
跟aggregate一样,transform也是一个有着严格条件的特殊函数:传入的函数只能产生两种结果,要么产生一个可以广播的标量值(如np.mean),要么产生一个相同大小的结果数组。最一般化的GroupBy方法是apply,本节剩余部分将重点讲解它。如图9-1所示,apply会将待处理的对象拆分成多个片段,然后对各片段调用传入的函数,最后尝试将各片段组合到一起。
回到之前那个小费数据集,假设你想要根据分组选出最高的5个tip_pct值。首先,编写一个选取指定列具有最大值的行的函数译注5:
- In [88]: def top(df, n=5, column='tip_pct'):
- ...: return df.sort_index(by=column)[-n:]
- In [89]: top(tips, n=6)
- Out[89]:
- total_bill tip sex smoker day time size tip_pct
- 109 14.31 4.00 Female True Sat Dinner 2 0.279525
- 183 23.17 6.50 Male True Sun Dinner 4 0.280535
- 232 11.61 3.39 Male False Sat Dinner 2 0.291990
- 67 3.07 1.00 Female True Sat Dinner 1 0.325733
- 178 9.60 4.00 Female True Sun Dinner 2 0.416667
- 172 7.25 5.15 Male True Sun Dinner 2 0.710345
现在,如果对smoker分组并用该函数调用apply,就会得到:
- In [90]: tips.groupby('smoker').apply(top)
- Out[90]:
- total_bill tip sex smoker day time size tip_pct
- smoker
- No 88 24.71 5.85 Male False Thur Lunch 2 0.236746
- 185 20.69 5.00 Male False Sun Dinner 5 0.241663
- 51 10.29 2.60 Female False Sun Dinner 2 0.252672
- 149 7.51 2.00 Male False Thur Lunch 2 0.266312
- 232 11.61 3.39 Male False Sat Dinner 2 0.291990
- Yes 109 14.31 4.00 Female True Sat Dinner 2 0.279525
- 183 23.17 6.50 Male True Sun Dinner 4 0.280535
- 67 3.07 1.00 Female True Sat Dinner 1 0.325733
- 178 9.60 4.00 Female True Sun Dinner 2 0.416667
- 172 7.25 5.15 Male True Sun Dinner 2 0.710345
这里发生了什么?top函数在DataFrame的各个片段上调用,然后结果由pandas.concat组装到一起,并以分组名称进行了标记。于是,最终结果就有了一个层次化索引,其内层索引值来自原DataFrame。
如果传给apply的函数能够接受其他参数或关键字,则可以将这些内容放在函数名后面一并传入:
- In [91]: tips.groupby(['smoker', 'day']).apply(top, n=1, column='total_bill')
- Out[91]:
- total_bill tip sex smoker day time size tip_pct
- smoker day
- No Fri 94 22.75 3.25 Female False Fri Dinner 2 0.142857
- Sat 212 48.33 9.00 Male False Sat Dinner 4 0.186220
- Sun 156 48.17 5.00 Male False Sun Dinner 6 0.103799
- Thur 142 41.19 5.00 Male False Thur Lunch 5 0.121389
- Yes Fri 95 40.17 4.73 Male True Fri Dinner 4 0.117750
- Sat 170 50.81 10.00 Male True Sat Dinner 3 0.196812
- Sun 182 45.35 3.50 Male True Sun Dinner 3 0.077178
- Thur 197 43.11 5.00 Female True Thur Lunch 4 0.115982
注意: 除这些基本用法之外,能否充分发挥apply的威力很大程度上取决于你的创造力。传入的那个函数能做什么全由你说了算,它只需返回一个pandas对象或标量值即可。本章后续部分的示例主要用于讲解如何利用groupby解决各种各样的问题。
可能你已经想起来了,之前我在GroupBy对象上调用过describe:
- In [92]: result = tips.groupby('smoker')['tip_pct'].describe()
- In [93]: result
- Out[93]:
- smoker
- No count 151.000000
- mean 0.159328
- std 0.039910
- min 0.056797
- 25% 0.136906
- 50% 0.155625
- 75% 0.185014
- max 0.291990
- Yes count 93.000000
- mean 0.163196
- std 0.085119
- min 0.035638
- 25% 0.106771
- 50% 0.153846
- 75% 0.195059
- max 0.710345
- In [94]: result.unstack('smoker')
- Out[94]:
- smoker No Yes
- count 151.000000 93.000000
- mean 0.159328 0.163196
- std 0.039910 0.085119
- min 0.056797 0.035638
- 25% 0.136906 0.106771
- 50% 0.155625 0.153846
- 75% 0.185014 0.195059
- max 0.291990 0.710345
在GroupBy中,当你调用诸如describe之类的方法时,实际上只是应用了下面两条代码的快捷方式而已:
- f = lambda x: x.describe()
- grouped.apply(f)
禁止分组键
从上面的例子中可以看出,分组键会跟原始对象的索引共同构成结果对象中的层次化索引。将group_keys=False传入groupby即可禁止该效果:
- In [95]: tips.groupby('smoker', group_keys=False).apply(top)
- Out[95]:
- total_bill tip sex smoker day time size tip_pct
- 88 24.71 5.85 Male False Thur Lunch 2 0.236746
- 185 20.69 5.00 Male False Sun Dinner 5 0.241663
- 51 10.29 2.60 Female False Sun Dinner 2 0.252672
- 149 7.51 2.00 Male False Thur Lunch 2 0.266312
- 232 11.61 3.39 Male False Sat Dinner 2 0.291990
- 109 14.31 4.00 Female True Sat Dinner 2 0.279525
- 183 23.17 6.50 Male True Sun Dinner 4 0.280535
- 67 3.07 1.00 Female True Sat Dinner 1 0.325733
- 178 9.60 4.00 Female True Sun Dinner 2 0.416667
- 172 7.25 5.15 Male True Sun Dinner 2 0.710345
分位数和桶分析
我曾在第7章中讲过,pandas有一些能根据指定面元或样本分位数将数据拆分成多块的工具(比如cut和qcut)。将这些函数跟groupby结合起来,就能非常轻松地实现对数据集的桶(bucket)或分位数(quantile)分析了。以下面这个简单的随机数据集为例,我们利用cut将其装入长度相等的桶中:
- In [96]: frame = DataFrame({'data1': np.random.randn(1000),
- ...: 'data2': np.random.randn(1000)})
- In [97]: factor = pd.cut(frame.data1, 4)
- In [98]: factor[:10]
- Out[98]:
- Categorical:
- array([(-1.23, 0.489], (-2.956, -1.23], (-1.23, 0.489], (0.489, 2.208],
- (-1.23, 0.489], (0.489, 2.208], (-1.23, 0.489], (-1.23, 0.489],
- (0.489, 2.208], (0.489, 2.208]], dtype=object)
- Levels (4): Index([(-2.956, -1.23], (-1.23, 0.489], (0.489, 2.208],
- (2.208, 3.928]], dtype=object)
由cut返回的Factor对象可直接用于groupby。因此,我们可以像下面这样对data2做一些统计计算:
- In [99]: def get_stats(group):
- ...: return {'min': group.min(), 'max': group.max(),
- ...: 'count': group.count(), 'mean': group.mean()}
- In [100]: grouped = frame.data2.groupby(factor)
- In [101]: grouped.apply(get_stats).unstack()
- Out[101]:
- count max mean min
- data1
- (-1.23, 0.489] 598 3.260383 -0.002051 -2.989741
- (-2.956, -1.23] 95 1.670835 -0.039521 -3.399312
- (0.489, 2.208] 297 2.954439 0.081822 -3.745356
- (2.208, 3.928] 10 1.765640 0.024750 -1.929776
这些都是长度相等的桶。要根据样本分位数得到大小相等的桶,使用qcut即可译注6。传入labels=False即可只获取分位数的编号。
- # 返回分位数编号
- In [102]: grouping = pd.qcut(frame.data1, 10, labels=False)
- In [103]: grouped = frame.data2.groupby(grouping)
- In [104]: grouped.apply(get_stats).unstack()
- Out[104]:
- count max mean min
- 0 100 1.670835 -0.049902 -3.399312
- 1 100 2.628441 0.030989 -1.950098
- 2 100 2.527939 -0.067179 -2.925113
- 3 100 3.260383 0.065713 -2.315555
- 4 100 2.074345 -0.111653 -2.047939
- 5 100 2.184810 0.052130 -2.989741
- 6 100 2.458842 -0.021489 -2.223506
- 7 100 2.954439 -0.026459 -3.056990
- 8 100 2.735527 0.103406 -3.745356
- 9 100 2.377020 0.220122 -2.064111
示例:用特定于分组的值填充缺失值
对于缺失数据的清理工作,有时你会用dropna将其滤除,而有时则可能会希望用一个固定值或由数据集本身所衍生出来的值去填充NA值。这时就得使用fillna这个工具了。在下面这个例子中,我用平均值去填充NA值:
- In [105]: s = Series(np.random.randn(6))
- In [106]: s[::2] = np.nan
- In [107]: s
- Out[107]:
- 0 NaN
- 1 -0.125921
- 2 NaN
- 3 -0.884475
- 4 NaN
- 5 0.227290
- In [108]: s.fillna(s.mean())
- Out[108]:
- 0 -0.261035
- 1 -0.125921
- 2 -0.261035
- 3 -0.884475
- 4 -0.261035
- 5 0.227290
假设你需要对不同的分组填充不同的值。可能你已经猜到了,只需将数据分组,并使用apply和一个能够对各数据块调用fillna的函数即可。下面是一些有关美国几个州的示例数据,这些州又被分为东部和西部:
- In [109]: states = ['Ohio', 'New York', 'Vermont', 'Florida',
- ...: 'Oregon', 'Nevada', 'California', 'Idaho']
- In [110]: group_key = ['East'] * 4 + ['West'] * 4
- In [111]: data = Series(np.random.randn(8), index=states)
- In [112]: data[['Vermont', 'Nevada', 'Idaho']] = np.nan
- In [113]: data
- Out[113]:
- Ohio 0.922264
- New York -2.153545
- Vermont NaN
- Florida -0.375842
- Oregon 0.329939
- Nevada NaN
- California 1.105913
- Idaho NaN
- In [114]: data.groupby(group_key).mean()
- Out[114]:
- East -0.535707
- West 0.717926
我们可以用分组平均值去填充NA值,如下所示:
- In [115]: fill_mean = lambda g: g.fillna(g.mean())
- In [116]: data.groupby(group_key).apply(fill_mean)
- Out[116]:
- Ohio 0.922264
- New York -2.153545
- Vermont -0.535707
- Florida -0.375842
- Oregon 0.329939
- Nevada 0.717926
- California 1.105913
- Idaho 0.717926
此外,也可以在代码中预定义各组的填充值。由于分组具有一个name属性,所以我们可以拿来用一下:
- In [117]: fill_values = {'East': 0.5, 'West': -1}
- In [118]: fill_func = lambda g: g.fillna(fill_values[g.name])
- In [119]: data.groupby(group_key).apply(fill_func)
- Out[119]:
- Ohio 0.922264
- New York -2.153545
- Vermont 0.500000
- Florida -0.375842
- Oregon 0.329939
- Nevada -1.000000
- California 1.105913
- Idaho -1.000000
示例:随机采样和排列
假设你想要从一个大数据集中随机抽取样本以进行蒙特卡罗模拟(Monte Carlo simulation)或其他分析工作。“抽取”的方式有很多,其中一些的效率会比其他的高很多。一个办法是,选取np.random.permutation(N)的前K个元素,其中N为完整数据的大小,K为期望的样本大小。作为一个更有趣的例子,下面是构造一副英语型扑克牌的一个方式:
- # 红桃(Hearts)、黑桃(Spades)、梅花(Clubs)、方片(Diamonds)
- suits = ['H', 'S', 'C', 'D']
- card_val = (range(1, 11) + [10] * 3) * 4
- base_names = ['A'] + range(2, 11) + ['J', 'K', 'Q']
- cards = []
- for suit in ['H', 'S', 'C', 'D']:
- cards.extend(str(num) + suit for num in base_names)
- deck = Series(card_val, index=cards)
现在我有了一个长度为52的Series,其索引为牌名,值则是21点或其他游戏中用于计分的点数(为了简单起见,我当A的点数为1):
- In [121]: deck[:13]
- Out[121]:
- AH 1
- 2H 2
- 3H 3
- 4H 4
- 5H 5
- 6H 6
- 7H 7
- 8H 8
- 9H 9
- 10H 10
- JH 10
- KH 10
- QH 10
现在,根据我上面所讲的,从整副牌中抽出5张,代码如下:
- In [122]: def draw(deck, n=5):
- ...: return deck.take(np.random.permutation(len(deck))[:n])
- In [123]: draw(deck)
- Out[123]:
- AD 1
- 8C 8
- 5H 5
- KC 10
- 2C 2
假设你想要从每种花色中随机抽取两张牌。由于花色是牌名的最后一个字符,所以我们可以据此进行分组,并使用apply:
- In [124]: get_suit = lambda card: card[-1] # 只要最后一个字母就可以了
- In [125]: deck.groupby(get_suit).apply(draw, n=2)
- Out[125]:
- C 2C 2
- 3C 3
- D KD 10
- 8D 8
- H KH 10
- 3H 3
- S 2S 2
- 4S 4
- # 另一种办法
- In [126]: deck.groupby(get_suit, group_keys=False).apply(draw, n=2)
- Out[126]:
- KC 10
- JC 10
- AD 1
- 5D 5
- 5H 5
- 6H 6
- 7S 7
- KS 10
示例:分组加权平均数和相关系数
根据groupby的“拆分-应用-合并”范式,DataFrame的列与列之间或两个Series之间的运算(比如分组加权平均)成为一种标准作业。以下面这个数据集为例,它含有分组键、值以及一些权重值:
- In [127]: df = DataFrame({'category': ['a', 'a', 'a', 'a', 'b', 'b', 'b', 'b'],
- ...: 'data': np.random.randn(8),
- ...: 'weights': np.random.rand(8)})
- In [128]: df
- Out[128]:
- category data weights
- 0 a 1.561587 0.957515
- 1 a 1.219984 0.347267
- 2 a -0.482239 0.581362
- 3 a 0.315667 0.217091
- 4 b -0.047852 0.894406
- 5 b -0.454145 0.918564
- 6 b -0.556774 0.277825
- 7 b 0.253321 0.955905
然后可以利用category计算分组加权平均数:
- In [129]: grouped = df.groupby('category')
- In [130]: get_wavg = lambda g: np.average(g['data'], weights=g['weights'])
- In [131]: grouped.apply(get_wavg)
- Out[131]:
- category
- a 0.811643
- b -0.122262
这个例子比较无聊,所以再看一个稍微实际点的例子——来自Yahoo!Finance的数据集,其中含有标准普尔500指数(SPX字段)和几只股票的收盘价:
- In [132]: close_px = pd.read_csv('ch09/stock_px.csv', parse_dates=True, index_col=0)
- In [133]: close_px
- Out[133]:
- <class 'pandas.core.frame.DataFrame'>
- DatetimeIndex: 2214 entries, 2003-01-02 00:00:00 to 2011-10-14 00:00:00
- Data columns:
- AAPL 2214 non-null values
- MSFT 2214 non-null values
- XOM 2214 non-null values
- SPX 2214 non-null values
- dtypes: float64(4)
- In [134]: close_px[-4:]
- Out[134]:
- AAPL MSFT XOM SPX
- 2011-10-11 400.29 27.00 76.27 1195.54
- 2011-10-12 402.19 26.96 77.16 1207.25
- 2011-10-13 408.43 27.18 76.37 1203.66
- 2011-10-14 422.00 27.27 78.11 1224.58
来做一个比较有趣的任务:计算一个由日收益率(通过百分数变化计算)与SPX之间的年度相关系数组成的DataFrame。下面是一个实现办法:
- In [135]: rets = close_px.pct_change().dropna()
- In [136]: spx_corr = lambda x: x.corrwith(x['SPX'])
- In [137]: by_year = rets.groupby(lambda x: x.year)
- In [138]: by_year.apply(spx_corr)
- Out[138]:
- AAPL MSFT XOM SPX
- 2003 0.541124 0.745174 0.661265 1
- 2004 0.374283 0.588531 0.557742 1
- 2005 0.467540 0.562374 0.631010 1
- 2006 0.428267 0.406126 0.518514 1
- 2007 0.508118 0.658770 0.786264 1
- 2008 0.681434 0.804626 0.828303 1
- 2009 0.707103 0.654902 0.797921 1
- 2010 0.710105 0.730118 0.839057 1
- 2011 0.691931 0.800996 0.859975 1
当然,你还可以计算列与列之间的相关系数:
- # 苹果和微软的年度相关系数
- In [139]: by_year.apply(lambda g: g['AAPL'].corr(g['MSFT']))
- Out[139]:
- 2003 0.480868
- 2004 0.259024
- 2005 0.300093
- 2006 0.161735
- 2007 0.417738
- 2008 0.611901
- 2009 0.432738
- 2010 0.571946
- 2011 0.581987
示例:面向分组的线性回归
顺着上一个例子继续,你可以用groupby执行更为复杂的分组统计分析,只要函数返回的是pandas对象或标量值即可。例如,我可以定义下面这个regress函数(利用statsmodels库)对各数据块执行普通最小二乘法(Ordinary Least Squares,OLS)回归:
- import statsmodels.api as sm
- def regress(data, yvar, xvars):
- Y = data[yvar]
- X = data[xvars]
- X['intercept'] = 1.
- result = sm.OLS(Y, X).fit()
- return result.params
现在,为了按年计算AAPL对SPX收益率的线性回归,我执行:
- In [141]: by_year.apply(regress, 'AAPL', ['SPX'])
- Out[141]:
- SPX intercept
- 2003 1.195406 0.000710
- 2004 1.363463 0.004201
- 2005 1.766415 0.003246
- 2006 1.645496 0.000080
- 2007 1.198761 0.003438
- 2008 0.968016 -0.001110
- 2009 0.879103 0.002954
- 2010 1.052608 0.001261
- 2011 0.806605 0.001514
译注5:原文比较拗口,其实就是“在指定列找出最大值,然后把这个值所在的行选取出来”。
译注6:补充说明一下。“长度相等的桶”指的是“区间大小相等”,“大小相等的桶”指的是“数据点数量相等”。