对数据集进行分组并对各组应用一个函数(无论是聚合还是转换),通常是数据分析工作中的重要环节。在将数据集加载、融合、准备好之后,通常就是计算分组统计或生成透视表。pandas
提供了一个灵活高效的gruopby`功能,它使你能以一种自然的方式对数据集进行切片、切块、摘要等操作。
在本章中,你将会学到:
- 使用一个或多个键(形式可以是函数、数组或DataFrame列名)分割pandas对象。
- 计算分组的概述统计,比如数量、平均值或标准差,或是用户定义的函数。
- 应用组内转换或其他运算,如规格化、线性回归、排名或选取子集等。
- 计算透视表或交叉表。
- 执行分位数分析以及其它统计分组分析。
笔记:对时间序列数据的聚合(groupby的特殊用法之一)也称作重采样(resampling),本书将在第11章中单独对其进行讲解。
一、GroupBy机制
- 分组键可以有多种形式,且类型不必相同:
- 列表或数组,其长度与待分组的轴一样。
- 表示DataFrame某个列名的值。
- 字典或Series,给出待分组轴上的值与分组名之间的对应关系。
- 函数,用于处理轴索引或索引中的各个标签。
注意,后三种都只是快捷方式而已,其最终目的仍然是产生一组用于拆分对象的值。
- 传入一列:
grouped = df['data1'].groupby(df['key1']) # 访问data1,并根据key1调用groupby
grouped.mean() # 基于分组结果求平均值
grouped.sum() # 基于分组结果求和
- 如果我们一次传入多个数组的列表,就会得到不同的结果:
In[15]: means = df['data1'].groupby([df['key1'], df['key2']]).mean()
In[16]: means # 得到的Series具有一个层次化索引
Out[16]:
key1 key2
a one 0.880536
two 0.478943
b one -0.519439
two -0.555730
Name: data1, dtype: float64
- 实际上,分组键可以是任何长度适当的数组:
In[18]: states = np.array(['Ohio','California','California','Ohio','Ohio'])
In[19]: years = np.array([2005,2005,2006,2005,2006])
In[20]: df['data1'].groupby([states, years]).mean()
Out[20]:
California 2005 0.071615
2006 -1.660034
Ohio 2005 0.919267
2006 -0.949231
Name: data1, dtype: float64
- 通常,分组信息就位于相同的要处理
DataFrame
中。因此可以将列名(可以是字符串、数字或其他Python对象)用作分组键:
In[21]: df.groupby('key1').mean() # key2不是数值列,被从结果中排除
Out[21]:
data1 data2
key1
a 0.7466720.910916
b -0.5375850.525384
In[22]: df.groupby(['key1','key2']).mean()
Out[22]:
data1 data2
key1 key2
a one 0.8805361.319920
two 0.4789430.092908
b one -0.5194390.281746
two -0.5557300.769023
- 可以用
GroupBy
的size
方法,获取大小信息:df.groupby(['key1','key2']).size()
注意,任何分组关键词中的缺失值,都会被从结果中除去。
GroupBy
对象支持迭代,可以产生一组二元元组(由分组名和数据块组成)。
for name, group in df.groupby('key1'):
print(name)
print(group)
对于多重键的情况,元组的第一个元素将会是由键值组成的元组。
8. 将这些数据片段做成一个字典可能会比较有用:pieces = dict(list(df.groupby('key1')))
9. groupby
默认是在axis=0
上进行分组的,通过设置也可以在其他任何轴上进行分组。
10. 对于由DataFrame产生的GroupBy对象,如果用一个(单个字符串)或一组(字符串数组)列名对其进行索引,就能实现选取部分列进行聚合的目的。
df.groupby('key1')['data1'] # 是以下代码的语法糖
df['data1'].groupby(df['key1'])
- 通过字典或Series进行分组
people = pd.DataFrame(np.random.randn(5,5), columns=['a','b','c','d','e'], index=['Joe','Steve','Wes','Jim','Travis'])
people.iloc[2:3, [1,2]] = np.nan
mapping ={'a':'red','b':'red','c':'blue', 'd':'blue','e':'red','f':'orange'} # 存在未使用的分组键是可以的
by_col = people.groupby(mapping, axis=1) # 通过字典分组
by_col.sum()
blue red
Joe 0.440924 0.236276
Steve -2.023552 2.156495
Wes 1.714252 0.495760
Jim 4.370667 -0.784421
Travis 1.526930 1.694027
map_series = pd.Series(mapping) # 将字典转换为Series
people.groupby(map_series, axis=1).count() # 通过Series分组
- 通过函数进行分组:任何被当做分组键的函数都会在各个**索引值(index)**上被调用一次,其返回值就会被用作分组名称。
people.groupby(len).sum() # 索引值为人的名字
a b c d e
3 2.668444 -1.767265 3.785625 2.740218 -0.953565
5 0.764664 0.779645 -1.521141 -0.502411 0.612186
6 0.051568 1.038692 0.474233 1.052698 0.603767
- 将函数跟数组、列表、字典、Series混合使用也不是问题,因为任何东西在内部都会被转换为数组:
key_list =['one','one','one','two','two']
people.groupby([len, key_list]).min()
- 根据索引级别分组:层次化索引数据集最方便的地方就在于它能够根据轴索引的一个级别进行聚合:
columns = pd.MultiIndex.from_arrays([['US','US','US','JP','JP'], [1,3,5,1,3]], names=['cty','tenor'])
hier_df = pd.DataFrame(np.random.randn(4,5), columns=columns)
hier_df.groupby(level='cty', axis=1).count() # 要根据级别分组,使用level关键字传递级别序号或名字
cty JP US
0 2 3
1 2 3
2 2 3
3 2 3
二、数据聚合
- 聚合指的是任何能够从数组产生标量值的数据转换过程。比如
mean
、count
、min
以及sum
等。
除了这些方法,可以使用自己发明的聚合运算,还可以调用分组对象上已经定义好的任何方法。例如,quantile
可以计算Series
或DataFrame
列的样本分位数。
- 实际上,GroupBy会高效地对
Series
进行切片,然后对各片调用piece.quantile(0.9)
,最后将这些结果组装成最终结果:
grouped = df.groupby('key1')
grouped['data1'].quantile(0.9)
- 如果要使用你自己的聚合函数,只需将其传入
aggregate
或agg
方法即可:
def peak_to_peak(arr):
return arr.max() - arr.min()
grouped.agg(peak_to_peak)
- 有些方法(如describe)也可以用在这里,即使严格来讲,它们并非聚合运算:
grouped.describe()
笔记:自定义聚合函数要比那些经过优化的函数慢得多。这是因为在构造中间分组数据块时存在非常大的开销(函数调用、数据重排等)。
- 面向列的多函数应用:有时我们可能希望对不同的列使用不同的聚合函数,或一次应用多个函数,下面通过一些示例来进行讲解。
- 可以将函数名以字符串的形式传入:
grouped = tips.groupby(['day', 'smoker']) # 根据天和smoker对tips进行分组
grouped_pct = grouped['tip_pct']
grouped_pct.agg('mean')
- 如果传入一组函数或函数名,得到的
DataFrame
的列就会以相应的函数命名:
grouped_pct.agg(['mean', 'std', peak_to_peak])
mean std peak_to_peak
day smoker
Fri No 0.179740 0.039458 0.094263
Yes 0.216293 0.077530 0.242219
Sat No 0.190412 0.058626 0.352192
Yes 0.179833 0.089496 0.446137
Sun No 0.193617 0.060302 0.274897
Yes 0.322021 0.538061 2.382107
Thur No 0.193424 0.056065 0.284273
Yes 0.198508 0.057170 0.219047
- 如果传入的是一个由
(name,function)
元组组成的列表,则各元组的第一个元素就会被用作DataFrame
的列名(可以将这种二元元组列表看做一个有序映射)
grouped_pct.agg([('foo', 'mean'), ('bar', np.std)])
- 对tip_pct和total_bill列计算三个统计信息
functions = ['count', 'mean', 'max']
result = grouped['tip_pct', 'total_bill'].agg(functions) # 结果DataFrame拥有层次化的列,这相当于分别对各列进行聚合,然后用concat将结果组装到一起
- 对一个列或不同的列应用不同的函数,具体的办法是向
agg
传入一个从列名映射到函数的字典:
In [71]: grouped.agg({'tip' : np.max, 'size' : 'sum'})
Out[71]:
tip size
day smoker
Fri No 3.50 9
Yes 4.73 31
Sat No 9.00 115
Yes 10.00 104
Sun No 6.00 167
Yes 6.50 49
Thur No 6.70 112
Yes 5.00 40
In [72]: grouped.agg({'tip_pct' : ['min', 'max', 'mean', 'std'], 'size' : 'sum'})
只有将多个函数应用到至少一列时,
DataFrame
才会拥有层次化的列。
- 以“没有行索引”的形式返回聚合数据:
In [73]: tips.groupby(['day', 'smoker'], as_index=False).mean()
Out[73]:
day smoker total_bill tip size tip_pct
0 Fri No 18.420000 2.812500 2.250000 0.151650
1 Fri Yes 16.813333 2.714000 2.066667 0.174783
2 Sat No 19.661778 3.102889 2.555556 0.158048
3 Sat Yes 21.276667 2.875476 2.476190 0.147906
4 Sun No 20.506667 3.167895 2.929825 0.160113
5 Sun Yes 24.120000 3.516842 2.578947 0.187250
6 Thur No 17.113111 2.673778 2.488889 0.160298
7 Thur Yes 19.190588 3.030000 2.352941 0.163863
当然,对结果调用reset_index
也能得到这种形式的结果。但使用as_index=False
方法可以避免一些不必要的计算。
三、apply:一般性的“拆分-应用-合并”
最通用的GroupBy
方法是apply
,本节剩余部分将重点讲解它。
-
如下图所示,
apply
会将待处理的对象拆分成多个片段,然后对各片段调用传入的函数,最后尝试将各片段组合到一起。
-
假设你想要根据分组选出最高的5个
tip_pct
值:
top
函数在DataFrame
的各个片段上调用,然后结果由pandas.concat
组装到一起,并以分组名称进行了标记。于是,最终结果就有了一个层次化索引,其内层索引值来自原DataFrame
。
def top(df, n=5, column='tip_pct'):
return df.sort_values(by=column)[-n:]
tips.groupby('smoker').apply(top)
total_bill tip smoker day time size tip_pct
smoker
No 88 24.71 5.85 No Thur Lunch 2 0.236746
185 20.69 5.00 No Sun Dinner 5 0.241663
51 10.29 2.60 No Sun Dinner 2 0.252672
149 7.51 2.00 No Thur Lunch 2 0.266312
232 11.61 3.39 No Sat Dinner 2 0.291990
Yes 109 14.31 4.00 Yes Sat Dinner 2 0.279525
183 23.17 6.50 Yes Sun Dinner 4 0.280535
67 3.07 1.00 Yes Sat Dinner 1 0.325733
178 9.60 4.00 Yes Sun Dinner 2 0.416667
172 7.25 5.15 Yes Sun Dinner 2 0.710345
- 如果传给apply的函数能够接受其他参数或关键字,则可以将这些内容放在函数名后面一并传入:
tips.groupby(['smoker', 'day']).apply(top, n=1, column='total_bill')
笔记:除这些基本用法之外,能否充分发挥apply的威力很大程度上取决于你的创造力。传入的那个函数能做什么全由你说了算,它只需返回一个
pandas
对象或标量值即可。本章后续部分的示例主要用于讲解如何利用groupby
解决各种各样的问题。
- 在
GroupBy
中,当你调用诸如describe
之类的方法时,实际上只是应用了下面两条代码的快捷方式而已:
f = lambda x: x.describe()
grouped.apply(f)
- 从上面的例子中可以看出,分组键会跟原始对象的索引共同构成结果对象中的层次化索引。将
group_keys=False
传入groupby
即可禁止该效果:
tips.groupby('smoker', group_keys=False).apply(top)
注意,
group_keys
与as_index
的功能有一定区别。as_index
是限制显示分组键,会以数字序号替代,但group_keys
则是不将分组键作为索引,只显示之前的行索引。
- 分位数和桶分析
frame = pd.DataFrame({'data1': np.random.randn(1000), 'data2': np.random.randn(1000)})
quartiles = pd.cut(frame.data1, 4) # 利用cut将frame 装入长度相等的桶中
def get_stats(group):
return {'count': group.count(), 'mean': group.mean(), 'max': group.max(), 'min': group.min()}
# 由cut返回的Categorical对象可直接传递到groupby
grouped = frame.data2.groupby(quartiles)
grouped.apply(get_stats).unstack()
Out[87]:
count max mean min
data1
(-2.956, -1.23] 95.0 1.670835 -0.039521 -3.399312
(-1.23, 0.489] 598.0 3.260383 -0.002051 -2.989741
(0.489, 2.208] 297.0 2.954439 0.081822 -3.745356
(2.208, 3.928] 10.0 1.765640 0.024750 -1.929776
- 对于缺失数据的清理工作,有时你会用dropna将其替换掉,而有时则可能会希望用一个固定值或由数据集本身所衍生出来的值去填充NA值。这时就得使用fillna这个工具了。
s = pd.Series(np.random.randn(6))
s[::2] = np.nan
s.fillna(s.mean()
- 假设需要对不同的分组填充不同的值。一种方法是将数据分组,并使用
apply
和一个能够对各数据块调用fillna
的函数即可。
states = ['Ohio', 'New York', 'Vermont', 'Florida', 'Oregon', 'Nevada', 'California', 'Idaho']
group_key = ['East'] * 4 + ['West'] * 4
data = pd.Series(np.random.randn(8), index=states)
data[['Vermont', 'Nevada', 'Idaho']] = np.nan
fill_mean = lambda g: g.fillna(g.mean()) # 用分组平均值去填充NA值
data.groupby(group_key).apply(fill_mean)
# 也可以在代码中预定义各组的填充值
fill_values = {'East': 0.5, 'West': -1}
fill_func = lambda g: g.fillna(fill_values[g.name])
data.groupby(group_key).apply(fill_func)
四、透视表和交叉表
- 透视表(pivot table)是各种电子表格程序和其他数据分析软件中一种常见的数据汇总工具。它根据一个或多个键对数据进行聚合,并根据行和列上的分组键将数据分配到各个矩形区域中。
DataFrame
有一个pivot_table
方法,此外还有一个顶级的pandas.pivot_table
函数。除能为groupby
提供便利之外,pivot_table
还可以添加分项小计,也叫做margins
。- 回到小费数据集,假设我想要根据day和smoker计算分组平均数(
pivot_table
的默认聚合类型),并将day和smoker放到行上:
In [130]: tips.pivot_table(index=['day', 'smoker'])
Out[130]:
size tip tip_pct total_bill
day smoker
Fri No 2.250000 2.812500 0.151650 18.420000
Yes 2.066667 2.714000 0.174783 16.813333
Sat No 2.555556 3.102889 0.158048 19.661778
Yes 2.476190 2.875476 0.147906 21.276667
Sun No 2.929825 3.167895 0.160113 20.506667
Yes 2.578947 3.516842 0.187250 24.120000
Thur No 2.488889 2.673778 0.160298 17.113111
Yes 2.352941 3.030000 0.163863 19.190588
- 假设我们只想聚合tip_pct和size,而且想根据time进行分组。我将smoker放到列上,把day放到行上:
In [131]: tips.pivot_table(['tip_pct', 'size'], index=['time', 'day'], columns='smoker')
Out[131]:
size tip_pct
smoker No Yes No Yes
time day
Dinner Fri 2.000000 2.222222 0.139622 0.165347
Sat 2.555556 2.476190 0.158048 0.147906
Sun 2.929825 2.578947 0.160113 0.187250
Thur 2.000000 NaN 0.159744 NaN
Lunch Fri 3.000000 1.833333 0.187735 0.188937
Thur 2.500000 2.352941 0.160311 0.163863
- 传入
margins=True
添加分项小计。这将会添加标签为All
的行和列,其值对应于单个等级中所有数据的分组统计:
In [132]: tips.pivot_table(['tip_pct', 'size'], index=['time', 'day'], columns='smoker', margins=True)
Out[132]:
size tip_pct
smoker No Yes All No Yes All
time day
Dinner Fri 2.000000 2.222222 2.166667 0.139622 0.165347 0.158916
Sat 2.555556 2.476190 2.517241 0.158048 0.147906 0.153152
Sun 2.929825 2.578947 2.842105 0.160113 0.187250 0.166897
Thur 2.000000 NaN 2.000000 0.159744 NaN 0.159744
Lunch Fri 3.000000 1.833333 2.000000 0.187735 0.188937 0.188765
Thur 2.500000 2.352941 2.459016 0.160311 0.163863 0.161301
All 2.668874 2.408602 2.569672 0.159328 0.163196 0.160803
这里,All值为平均数:不单独考虑烟民与非烟民(All列),不单独考虑行分组两个级别中的任何单项(All行)。
- 要使用其他的聚合函数,将其传给
aggfunc
即可。例如,使用count
或len
可以得到有关分组大小的交叉表(计数或频率):
tips.pivot_table('tip_pct', index=['time', 'smoker'], columns='day', aggfunc=len, margins=True)
- 交叉表(cross-tabulation,简称crosstab)是一种用于计算分组频率的特殊透视表。
crosstab
的前两个参数可以是数组或Series
,或是数组列表。就像小费数据:
In [140]: pd.crosstab([tips.time, tips.day], tips.smoker, margins=True)
Out[140]:
smoker No Yes All
time day
Dinner Fri 3 9 12
Sat 45 42 87
Sun 57 19 76
Thur 1 0 1
Lunch Fri 1 6 7
Thur 44 17 61
All 151 93 244
# 用pivot_table函数也能实现上述功能,但所有有区别
tips.pivot_table(['tip'], index=['time', 'day'], columns=['smoker'], aggfunc='count', margins=True, fill_value=0)
以上,欢迎交流。