• 10.3 apply:一般性的“拆分-应用-合并”
    • 禁止分组键
    • 分位数和桶分析
    • 示例:用特定于分组的值填充缺失值
    • 示例:随机采样和排列
    • 示例:分组加权平均数和相关系数
    • 示例:组级别的线性回归

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

    最通用的GroupBy方法是apply,本节剩余部分将重点讲解它。如图10-2所示,apply会将待处理的对象拆分成多个片段,然后对各片段调用传入的函数,最后尝试将各片段组合到一起。

    图10-2 分组聚合示例

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

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

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

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

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

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

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

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

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

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

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

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

    禁止分组键

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

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

    分位数和桶分析

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

    1. In [82]: frame = pd.DataFrame({'data1': np.random.randn(1000),
    2. ....: 'data2': np.random.randn(1000)})
    3. In [83]: quartiles = pd.cut(frame.data1, 4)
    4. In [84]: quartiles[:10]
    5. Out[84]:
    6. 0 (-1.23, 0.489]
    7. 1 (-2.956, -1.23]
    8. 2 (-1.23, 0.489]
    9. 3 (0.489, 2.208]
    10. 4 (-1.23, 0.489]
    11. 5 (0.489, 2.208]
    12. 6 (-1.23, 0.489]
    13. 7 (-1.23, 0.489]
    14. 8 (0.489, 2.208]
    15. 9 (0.489, 2.208]
    16. Name: data1, dtype: category
    17. Categories (4, interval[float64]): [(-2.956, -1.23] < (-1.23, 0.489] < (0.489, 2.
    18. 208] < (2.208, 3.928]]

    由cut返回的Categorical对象可直接传递到groupby。因此,我们可以像下面这样对data2列做一些统计计算:

    1. In [85]: def get_stats(group):
    2. ....: return {'min': group.min(), 'max': group.max(),
    3. ....: 'count': group.count(), 'mean': group.mean()}
    4. In [86]: grouped = frame.data2.groupby(quartiles)
    5. In [87]: grouped.apply(get_stats).unstack()
    6. Out[87]:
    7. count max mean min
    8. data1
    9. (-2.956, -1.23] 95.0 1.670835 -0.039521 -3.399312
    10. (-1.23, 0.489] 598.0 3.260383 -0.002051 -2.989741
    11. (0.489, 2.208] 297.0 2.954439 0.081822 -3.745356
    12. (2.208, 3.928] 10.0 1.765640 0.024750 -1.929776

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

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

    我们会在第12章详细讲解pandas的Categorical类型。

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

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

    1. In [91]: s = pd.Series(np.random.randn(6))
    2. In [92]: s[::2] = np.nan
    3. In [93]: s
    4. Out[93]:
    5. 0 NaN
    6. 1 -0.125921
    7. 2 NaN
    8. 3 -0.884475
    9. 4 NaN
    10. 5 0.227290
    11. dtype: float64
    12. In [94]: s.fillna(s.mean())
    13. Out[94]:
    14. 0 -0.261035
    15. 1 -0.125921
    16. 2 -0.261035
    17. 3 -0.884475
    18. 4 -0.261035
    19. 5 0.227290
    20. dtype: float64

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

    1. In [95]: states = ['Ohio', 'New York', 'Vermont', 'Florida',
    2. ....: 'Oregon', 'Nevada', 'California', 'Idaho']
    3. In [96]: group_key = ['East'] * 4 + ['West'] * 4
    4. In [97]: data = pd.Series(np.random.randn(8), index=states)
    5. In [98]: data
    6. Out[98]:
    7. Ohio 0.922264
    8. New York -2.153545
    9. Vermont -0.365757
    10. Florida -0.375842
    11. Oregon 0.329939
    12. Nevada 0.981994
    13. California 1.105913
    14. Idaho -1.613716
    15. dtype: float64

    [‘East’] * 4产生了一个列表,包括了[‘East’]中元素的四个拷贝。将这些列表串联起来。

    将一些值设为缺失:

    1. In [99]: data[['Vermont', 'Nevada', 'Idaho']] = np.nan
    2. In [100]: data
    3. Out[100]:
    4. Ohio 0.922264
    5. New York -2.153545
    6. Vermont NaN
    7. Florida -0.375842
    8. Oregon 0.329939
    9. Nevada NaN
    10. California 1.105913
    11. Idaho NaN
    12. dtype: float64
    13. In [101]: data.groupby(group_key).mean()
    14. Out[101]:
    15. East -0.535707
    16. West 0.717926
    17. dtype: float64

    我们可以用分组平均值去填充NA值:

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

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

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

    示例:随机采样和排列

    假设你想要从一个大数据集中随机抽取(进行替换或不替换)样本以进行蒙特卡罗模拟(Monte Carlo simulation)或其他分析工作。“抽取”的方式有很多,这里使用的方法是对Series使用sample方法:

    1. # Hearts, Spades, Clubs, Diamonds
    2. suits = ['H', 'S', 'C', 'D']
    3. card_val = (list(range(1, 11)) + [10] * 3) * 4
    4. base_names = ['A'] + list(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. deck = pd.Series(card_val, index=cards)

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

    1. In [108]: deck[:13]
    2. Out[108]:
    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
    16. dtype: int64

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

    1. In [109]: def draw(deck, n=5):
    2. .....: return deck.sample(n)
    3. In [110]: draw(deck)
    4. Out[110]:
    5. AD 1
    6. 8C 8
    7. 5H 5
    8. KC 10
    9. 2C 2
    10. dtype: int64

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

    1. In [111]: get_suit = lambda card: card[-1] # last letter is suit
    2. In [112]: deck.groupby(get_suit).apply(draw, n=2)
    3. Out[112]:
    4. C 2C 2
    5. 3C 3
    6. D KD 10
    7. 8D 8
    8. H KH 10
    9. 3H 3
    10. S 2S 2
    11. 4S 4
    12. dtype: int64

    或者,也可以这样写:

    1. In [113]: deck.groupby(get_suit, group_keys=False).apply(draw, n=2)
    2. Out[113]:
    3. KC 10
    4. JC 10
    5. AD 1
    6. 5D 5
    7. 5H 5
    8. 6H 6
    9. 7S 7
    10. KS 10
    11. dtype: int64

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

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

    1. In [114]: df = pd.DataFrame({'category': ['a', 'a', 'a', 'a',
    2. .....: 'b', 'b', 'b', 'b'],
    3. .....: 'data': np.random.randn(8),
    4. .....: 'weights': np.random.rand(8)})
    5. In [115]: df
    6. Out[115]:
    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 [116]: grouped = df.groupby('category')
    2. In [117]: get_wavg = lambda g: np.average(g['data'], weights=g['weights'])
    3. In [118]: grouped.apply(get_wavg)
    4. Out[118]:
    5. category
    6. a 0.811643
    7. b -0.122262
    8. dtype: float64

    另一个例子,考虑一个来自Yahoo!Finance的数据集,其中含有几只股票和标准普尔500指数(符号SPX)的收盘价:

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

    来做一个比较有趣的任务:计算一个由日收益率(通过百分数变化计算)与SPX之间的年度相关系数组成的DataFrame。下面是一个实现办法,我们先创建一个函数,用它计算每列和SPX列的成对相关系数:

    1. In [122]: spx_corr = lambda x: x.corrwith(x['SPX'])

    接下来,我们使用pct_change计算close_px的百分比变化:

    1. In [123]: rets = close_px.pct_change().dropna()

    最后,我们用年对百分比变化进行分组,可以用一个一行的函数,从每行的标签返回每个datetime标签的year属性:

    1. In [124]: get_year = lambda x: x.year
    2. In [125]: by_year = rets.groupby(get_year)
    3. In [126]: by_year.apply(spx_corr)
    4. Out[126]:
    5. AAPL MSFT XOM SPX
    6. 2003 0.541124 0.745174 0.661265 1.0
    7. 2004 0.374283 0.588531 0.557742 1.0
    8. 2005 0.467540 0.562374 0.631010 1.0
    9. 2006 0.428267 0.406126 0.518514 1.0
    10. 2007 0.508118 0.658770 0.786264 1.0
    11. 2008 0.681434 0.804626 0.828303 1.0
    12. 2009 0.707103 0.654902 0.797921 1.0
    13. 2010 0.710105 0.730118 0.839057 1.0
    14. 2011 0.691931 0.800996 0.859975 1.0

    当然,你还可以计算列与列之间的相关系数。这里,我们计算Apple和Microsoft的年相关系数:

    1. In [127]: by_year.apply(lambda g: g['AAPL'].corr(g['MSFT']))
    2. Out[127]:
    3. 2003 0.480868
    4. 2004 0.259024
    5. 2005 0.300093
    6. 2006 0.161735
    7. 2007 0.417738
    8. 2008 0.611901
    9. 2009 0.432738
    10. 2010 0.571946
    11. 2011 0.581987
    12. dtype: float64

    示例:组级别的线性回归

    顺着上一个例子继续,你可以用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 [129]: by_year.apply(regress, 'AAPL', ['SPX'])
    2. Out[129]:
    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