Pandas分组操作

分组操作在日常生活中使用极其广泛,想要实现分组操作,必须明确三个要素:分组依据、数据来源、操作及其返回结果。所以,分组代码的范式可以写作:

df.groupby(分组依据)[数据来源].使用操作

分组的依据可以是多种多样的,可以是某个列名,某几个列名,还可以是复杂的逻辑组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import pandas as pd

df = pd.read_csv('data/learn_pandas.csv')

# 依据是单一维度的列名:按照性别统计身高中位数
print(df.groupby('Gender')['Height'].median())
'''
Gender
Female 159.6
Male 173.4
Name: Height, dtype: float64
'''

# 依据是多维度的列名:根据学校和性别进行分组,统计身高的均值
print(df.groupby(['School', 'Gender'])['Height'].mean())
'''
School Gender
Fudan University Female 158.776923
Male 174.212500
Peking University Female 158.666667
Male 172.030000
Shanghai Jiao Tong University Female 159.122500
Male 176.760000
Tsinghua University Female 159.753333
Male 171.638889
Name: Height, dtype: float64
'''

# 依据是复杂逻辑组合:根据学生体重是否超过总体均值来分组,计算身高的均值
condition = df.Weight > df.Weight.mean()
print(df.groupby(condition)['Height'].mean())
'''
Weight
False 159.034646
True 172.705357
Name: Height, dtype: float64
'''

从上面的结果可以看出,分组输出的结果是以依据作为索引的,在数据来源上进行操作的结果。

Groupby对象

能够注意到,最终具体做分组操作时,所调用的方法都来自于 Pandas 中的 Groupby 对象,除了上文中使用的统计值相关的方法外,Groupby 对象还有很多属性和其他方法:

1
2
3
4
5
6
7
8
9
10
gb = df.groupby(['School', 'Grade'])
# 通过 ngroups 属性,可以访问分为了多少组
print(gb.ngroups)
# 通过 groups 属性,可以返回从组名映射到组索引列表的字典:
res = gb.groups
print(res.keys()) # 仅输出字典的key
# .size() 统计每个组的元素个数
print(gb.size())
# .get_group() 获取所在组对应的
print(gb.get_group(('Fudan University', 'Freshman')).iloc[:3, :3])

对于一个 Groupby 对象,使用最多的操作可以总结为三类:聚合、变换和过滤。它们分别对应如下的三种应用场景:

  • 聚合:依据性别分组,统计全国人口寿命的平均值
  • 变换:依据季节分组,对每一个季节的温度进行组内标准化
  • 过滤:依据班级分组,筛选出组内数学分数的平均值超过80 分的班级

聚合

Groupby 对象内置了许多聚合函数,较为常用的包括max、min、mean 和 median,还有一些常用的内置聚合函数整理如下表:

内置聚合函数 功能 内置聚合函数 功能
count() 统计分组中的非缺失值个数 nunique([dropna]) 返回每个维度去重元素的个数
all([skipna) 分组中所有值为真返回True,否则False any([skipna] 分组中任一值为真返回True,否则False
sum(numeric_only, min_count) 返回分组值之和 prod(numeric_only, min_count) 返回分组值之积
quantile([q, iternpolation]) 返回分组中指定分为数的值 mad([axis]) 返回指定轴上值的平均绝对偏差
std([ddof]) 返回分组的标准偏差,不包括缺失值 var([ddof]) 返回分组的方差,不包括缺失值
sem([ddof]) 返回分组平均值的标准误差,不包括缺失值 skew([axis]) 返回指定轴上的偏度系数
idxmin([axis, skipna]) 返回指定轴上第一次出现的最小值索引 idxmax([axis, skipna]) 返回指定轴上第一次出现的最大值索引

这些聚合函数当传入的数据来源包含多个列时,将按照列进行迭代计算:

1
2
3
4
5
6
7
8
9
gb = df.groupby('Gender')[['Height', 'Weight']]
print(gb.max())

'''
Height Weight
Gender
Female 170.2 63.0
Male 193.9 89.0
'''

虽然在 Groupby 对象内置了许多聚合函数,但仍然有以下不便之处:无法同时使用多个函数,无法对特定的列使用特定的聚合函数,无法使用自定义的聚合函数,无法直接对结果的列名在聚合前进行自定义命名。此时,我们可以通过使用 agg 函数来解决以上不便。

1
2
3
4
5
6
7
8
9
10
11
# 1. 一次使用多个内置聚合函数
print(gb.agg(['sum', 'idxmax', 'skew']))

# 2. 对特定的列使用特定的聚合函数
print(gb.agg({'Height':['mean','max'], 'Weight':'count'}))

# 3. 使用自定义函数(计算身高和体重的极差)
print(gb.agg(lambda x: x.mean()-x.min()))

# 4. 聚合结果重命名
print(gb.agg([('range', lambda x: x.max()-x.min()), ('my_sum', 'sum')]))

使用多个聚合函数的输出结果其列索引为多级索引,第一层为数据源,第二层为使用的聚合方法;对于方法和列的特殊对应,可以通过构造字典传入 agg 中实现;自定义函数,需要注意传入函数的参数是之前数据源中的列,逐列进行计算,且返回值必须是标量;想要对结果进行重命名,只需要将上述函数的位置改写成元组,元组的第一个元素为新的名字,第二个位置为原来的函数。

变换

变换函数的返回值是与数据来源相同长度的序列,Groupby 对象内置的变换函数中最常用的是累计函数:cumcount/cumsum/cumprod/cummax/cummin。另外,rank 方法也是一个实用的变换函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
data = {'Gender': ['M', 'M', 'M', 'F', 'F', 'M'], 'Height': [185, 172, 178, 152, 160, 181]}
test_df = pd.DataFrame(data)
print(test_df.groupby('Gender').cumcount())
'''
0 0
1 1
2 2
3 0
4 1
5 3
dtype: int64
'''

print(test_df.groupby('Gender').rank(method='average', ascending=True))
'''
Height
0 4.0
1 1.0
2 2.0
3 1.0
4 2.0
5 3.0
'''

# 对身高和体重进行分组标准化,即减去组均值后除以组的标准差
print(gb.transform(lambda x: (x-x.mean())/x.std()).head())
'''
Height Weight
0 -0.058760 -0.354888
1 -1.010925 -0.355000
2 2.167063 2.089498
3 NaN -1.279789
4 0.053133 0.159631
'''

# 返回是标量,会使得结果被广播到其所在的整个组
print(gb.transform('mean').head())
'''
Height Weight
0 159.19697 47.918519
1 173.62549 72.759259
2 173.62549 72.759259
3 159.19697 47.918519
4 173.62549 72.759259
'''

当用自定义变换时需要使用 transform 方法,被调用的自定义函数,其传入值为数据源的序列,最后的返回结果是行列索引与数据源一致的 DataFrame。前面提到了 transform 只能返回同长度的序列,但事实上还可以返回一个标量,这会使得结果被广播到其所在的整个组,这种标量广播的技巧在特征工程中是非常常见的。

过滤

组过滤指的是如果对一个组的全体所在行进行统计的结果返回 True 则会被保留,False 则该组会被过滤,最后把所有未被过滤的组其对应的所在行拼接起来作为 DataFrame 返回。

1
2
3
4
5
6
7
8
9
10
11
# 过滤得到所有容量大于100的组
print(gb.filter(lambda x: x.shape[0] > 100).head())

'''
Height Weight
0 158.9 46.0
3 NaN 41.0
5 158.0 51.0
6 162.5 52.0
7 161.9 50.0
'''

在 Groupby 对象中,定义了 filter 方法进行组的筛选,其中自定义函数的输入参数为数据源构成的 DataFrame 本身,且需保证自定义函数的返回为布尔值。

跨列分组

如果我们要对上述的分组求BMI值(体重/POW(身高, 2)),显然前面所讲的任何一种方法都无法处理,所以,我们引入了 apply 函数来实现复杂的分组操作。在设计上,apply 的自定义函数传入参数与 filter 完全一致,但返回值并不受限于布尔型。

1
2
3
4
5
6
7
8
9
10
11
12
13
def BMI(x):
Height = x['Height']/100
Weight = x['Weight']
BMI_value = Weight/Height**2
return BMI_value.mean()

print(gb.apply(BMI))
'''
Gender
Female 18.860930
Male 24.318654
dtype: float64
'''

除了返回标量之外,apply 方法还可以返回一维 Series 和二维 DataFrame。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import numpy as np
# 1. 自定义函数返回标量情况:结果得到的是Series,索引与agg的结果一致
gb = df.groupby(['Gender','Test_Number'])[['Height','Weight']]
print(gb.apply(lambda x: 0))
#print(gb.apply(lambda x: [0, 0])) # 虽然是列表,但是作为返回值仍然看作标量
'''
Gender Test_Number
Female 1 0
2 0
3 0
Male 1 0
2 0
3 0
dtype: int64
'''

# 2. 自定义函数返回Series情况:得到的是DataFrame,行索引与标量情况一致,列索引为Series的索引
print(gb.apply(lambda x: pd.Series([0,0],index=['a','b'])))
'''
a b
Gender Test_Number
Female 1 0 0
2 0 0
3 0 0
Male 1 0 0
2 0 0
3 0 0
'''

# 3. 自定义函数返回DataFrame情况:得到的是DataFrame,行索引最内层在每个组原先agg的结果索引上,
# 再加一层返回的DataFrame行索引,同时分组结果DataFrame的列索引和返回的DataFrame列索引一致
rest = gb.apply(lambda x: pd.DataFrame(np.ones((2,2)), index = ['a','b'],
columns=pd.Index([('w','x'),('y','z')])))
print(rest)
'''
w y
x z
Gender Test_Number
Female 1 a 1.0 1.0
b 1.0 1.0
2 a 1.0 1.0
b 1.0 1.0
3 a 1.0 1.0
b 1.0 1.0
Male 1 a 1.0 1.0
b 1.0 1.0
2 a 1.0 1.0
b 1.0 1.0
3 a 1.0 1.0
b 1.0 1.0
'''

最后需要强调的是,apply 函数的灵活性是以牺牲一定性能为代价换得的,除非需要使用跨列处理的分组处理,否则应当使用其他专门设计的 groupby 对象方法,否则在性能上会存在较大的差距。同时,在使用聚合函数和变换函数时,也应当优先使用内置函数,它们经过了高度的性能优化,一般而言在速度上都会快于用自定义函数来实现。