在日常数据分析时,经常会遇到需要按列分组 (groupby) 的任务,如计算某公司各部门的人数,计算各部门男女平均工资,计算不同年代的员工的平均工资等等。在进行这类运算时,Pandas 提供了 groupby 函数,大多数问题它都可以解决,但有一些问题使用 groupby 函数会略显麻烦,下面我们就这些问题展开细致的讨论。
groupby 是 pandas 中非常重要的一个函数, 主要用于数据分类和聚合计算. 其思想是“split-apply-combine”(拆分 - 应用 - 合并),如下图:
分组原理图
一、单列分组聚合
单列分组聚合是指把某一列作为键进行分组,然后对各组进行聚合运算。
它是上述分组原理的最简单应用,比如根据员工信息数据,计算各部门员工数。
问题分析:要计算各部门员工数,首先把部门作为键进行分组,然后对各组成员进行计数。
部分员工信息数据如下:
EIDNAMESURNAMEGENDERSTATEBIRTHDAYHIREDATEDEPTSALARY
1RebeccaMooreFCalifornia1974/11/202005/3/11R&D7000
2AshleyWilsonFNew York1980/7/192008/3/16Finance11000
3RachelJohnsonFNew Mexico1970/12/172010/12/1Sales9000
4EmilySmithFTexas1985/3/72006/8/15HR7000
5AshleySmithFTexas1975/5/132004/7/30R&D16000
………………………
Python代码
import pandas as pd
employee = pd.read_csv("Employees.csv")
dept_emp_num = employee.groupby('DEPT')['DEPT'].count()
print(dept_emp_num)
读取数据
分组计数
讨论:groupby(‘DEPT’) 将数据按照部门分组, count() 函数进行计数。
二、多列分组聚合
多列分组聚合是指把多列的值同时作为键进行分组,然后对各组进行聚合运算。
它和单列分组聚合类似,只是分组的键是多列组合而已。如根据员工信息数据,计算各部门男女员工的平均工资。
继续使用上例中的员工信息数据
问题分析:需要分组的键有两个,分别是部门和性别,只要把他们组合起来看作是一个键,然后当做单列分组聚合即可。
Python 代码
import pandas as pd
employee = pd.read_csv("Employees.csv")
dept_gender_salary = employee.groupby(['DEPT','GENDER'],as_index=False).SALARY.mean()
print(dept_gender_salary)
多列分组再聚合
讨论:groupby(['DEPT','GENDER']),分组的两列以列表的形式作为参数,as_index 表示是否把分组列作为索引,True 表示作为索引,这里使用 False 表示不作为索引。使用 mean() 函数计算工资的平均值。
三、根据衍生列分组聚合
根据衍生列分组聚合,是指需要分组的键并不直接在数据中,需要根据数据计算出一列新数据,把它作为键对数据进行分组。如计算不同年代的员工的平均工资。
问题分析:员工信息数据中并没有年代这一列,因此需要根据员工的生日列计算出来,把它作为键对员工数据进行分组,然后再求工资均值。
Python 代码
import pandas as pd
import numpy as np
employee = pd.read_csv("Employees.csv")
employee['BIRTHDAY']=pd.to_datetime(employee['BIRTHDAY'])
years_salary = employee.groupby(np.floor((employee['BIRTHDAY'].dt.year-1900)/10)).SALARY.mean()
print(years_salary)
生日列转换成日期格式
计算衍生数组并按此数组分组,再计算平均工资
讨论:年代数据在原数据中并不存在,使用 np.floor((employee['BIRTHDAY'].dt.year-1900)/10) 计算出衍生列表示年代,然后根据他分组并计算平均工资。
四、多个聚合
多个聚合,是指分组后对单列或者多列进行多种聚合。
(一) 多列单聚合
多列单聚合,指同时对多列聚合,但每列使用一种聚合方式。如:同时计算各部门员工的人数,平均工资。
问题分析:求员工人数可以对 EID 计数,求平均工资需要对工资列求均值,两列聚合但每列只用一种聚合方式。
Python 代码
import pandas as pd
employee = pd.read_csv("Employees.csv")
dept_agg = employee.groupby('DEPT',as_index=False).agg({'EID':'count','SALARY':'mean'})
print(dept_agg.rename(columns={'EID':'NUM','SALARY':'AVG_SALARY'}))
分组并对 EID 计数,对 SALARY 求平均
重命名列名
讨论:Pandas 的 agg()函数可以完成这类任务,各列以及各列的聚合方式以字典的形式作为参数传入 agg(),聚合的列作为字典的键,聚合方式作为字典的值,从而完成聚合运算。
(二) 单列多聚合
单列多聚合,指只对一列聚合,但聚合的方式有多种。如上述问题也可以直接对工资计数并求平均,此时是对工资进行了两种聚合——计数和平均。
Python 代码
import pandas as pd
employee = pd.read_csv("Employees.csv")
dept_agg = employee.groupby('DEPT').SALARY.agg(['count','mean']).reset_index()
print(dept_agg.rename(columns={'count':'NUM','mean':'AVG_SALARY'}))
对 SALARY 计数并求平均
重命名列名
讨论:如果是单列的不同聚合方式,则可以把聚合方式进行组合以列表的形式作为参数传入 agg()。
(三) 多列多聚合
多列多聚合,指对多列聚合同时也包含单列多聚合的组合聚合方式。聚合方式还可以是自己定义的函数,
如:计算各部门员工人数,平均工资和最大年龄。
问题分析:计算员工人数和平均工资,是对工资列计数并求平均(单列多聚合),求最大年龄,需对生日列使用自定义的函数计算出最大年龄。
Python 代码
import pandas as pd
import datetime
def max_age(s):
today = datetime. datetime.today().year
age = today-s.dt.year
return age.max()
employee = pd.read_csv("Employees.csv")
employee['BIRTHDAY']=pd.to_datetime(employee['BIRTHDAY'])
dept_agg = employee.groupby('DEPT').agg({'SALARY':['count','mean'],'BIRTHDAY':max_age})
dept_agg.columns = ['NUM','AVG_SALARY','MAX_AGE']
print(dept_agg.reset_index())
函数:求最大年龄
年份
求年龄
按 DEPT 分组,根据 SALARY 计数和求均值,BIRTHDAY 使用 max_age 计算最大年龄
修改列名
讨论:这种情况,聚合列和聚合方式还是按照字典的方式传入,但当某一列需要多种聚合方式时,则需要将其组合,以列表的形式作为字典的值。
五、分组聚合值复制
分组聚合值复制,指把分组聚合的结果转换成与该组等长的列,相当于把聚合的结果复制到该组的所有行。如:为员工信息数据新增一列各部门的平均工资。
问题分析:各部门的平均工资需要按照部门分组再对工资求平均,把平均工资的值添加到对应的组,并保持数据原序。
Python 代码
import pandas as pd
employee = pd.read_csv("Employees.csv")
employee['AVG_SALARY'] = employee.groupby('DEPT').SALARY.transform('mean')
print(employee)
按照 DEPT 分组并对 SALARY 求平均
讨论:按照部门分组后,对工资求均值。transform() 函数在组内求聚合值后会按照原索引的顺序返回结果,可以自动按照索引添加结果,从而保证原数据顺序不变。
六、分组子集处理
分组应用:指分组后对各组进行一些非聚合运算。比如分组排序,分组后不再关心聚合的结果,而是关心组内记录的顺序。如:将各部门按照入职时间从早到晚进行排序 。
问题分析:按照部门分组后,不再关心分组后的聚合结果,而是关心员工的入职时间顺序。分组后,对各组进行循环同时对组内成员按照入职时间排序就可以了。
Python 代码
import pandas as pd
employee = pd.read_csv("Employees.csv")
employee['HIREDATE']=pd.to_datetime(employee['HIREDATE'])
employee_new = employee.groupby('DEPT',as_index=False).apply(lambda x:x.sort_values('HIREDATE')).reset_index(drop=True)
print(employee_new)
修改入职时间格式
按 DEPT 分组,并对各组按照 HIREDATE 排序,最后重置索引
讨论:分组后需要对组内成员排序,可以使用 apply()函数结合 lambda 的方式,其中 lambda 表达式是对各组循环,使用 sort_values() 函数在组内部再排序,返回组内排序的结果。
简单的运算使用 lambda 函数计算,但有时会遇到比较复杂的计算,如:计算各部门年龄最大的员工和年龄最小的员工的工资差。
问题分析:首先需按照部门分组,分组后还需要找到年龄最大的员工和年龄最小的员工的记录,然后才能计算工资差。
Python 代码
import pandas as pd
def salary_diff(g):
max_age = g['BIRTHDAY'].idxmin()
min_age = g['BIRTHDAY'].idxmax()
diff = g.loc[max_age]['SALARY']-g.loc[min_age]['SALARY']
return diff
employee = pd.read_csv("Employees.csv")
employee['BIRTHDAY']=pd.to_datetime(employee['BIRTHDAY'])
salary_diff = employee.groupby('DEPT').apply(salary_diff)
print(salary_diff)
函数:计算各组工资差
年龄最大的索引
年龄最小的索引
计算工资差
按 DEPT 分组并使用自定义函数计算
讨论:使用 apply()结合自定义函数的方式。其中 apply() 会把分组的结果作为参数传入自定义函数。salary_diff() 函数是自定义函数,g 实质上就是 pandas 的 DataFrame 格式的数据框,这里是分组的结果。对它计算最大年龄和最小年龄的索引后,找到工资字段计算差即得到结果。
思考:
由上述讨论可见,熟练掌握 Pandas 的这些 groupby 方法对我们进行数据分析是特别有帮助的。
下面我们以 stack overflow 网站上的一些实际问题来进一步了解 groupby。
七、按位置分组
按位置分组,指不以某列作为键分组,而是以记录的位置作为键来分组。比如将数据每三行分到相同组或者按照位置分成奇数位置一组,偶数位置一组等。举例如下:
source:https://stackoverflow.com/questions/59110612/pandas-groupby-mode-every-n-rows
数据片段如下:
time a b
0 0.5 -2.0
1 0.5 -2.0
2 0.1 -1.0
3 0.1 -1.0
4 0.1 -1.0
5 0.5 -1.0
6 0.5 -1.0
7 0.5 -3.0
8 0.5 -1.0
希望每三行分成一组,并把众数作为该组的结果。理想的结果如下:
time a b
2 0.5 -2.0
5 0.1 -1.0
8 0.5 -1.0
问题分析:该问题的分组与现有的列没有关系,只与位置相关,因此需要衍生出一列作为分组依据,按位置做整数乘法即得到衍生列,然后据此分组即可。
Python 代码
import pandas as pd
import numpy as np
data = pd.read_csv("group3.txt",sep='\t')
res = data.groupby(np.arange(len(data)) // 3).agg(lambda x: x.mode().iloc[-1])
print(res)
按照衍生列分组,使用 agg 结合 lambda 的方式得到众数,取各组各列的最后 1 个众数作为结果
讨论:衍生列计算方式为 np.arange(len(data)) // 3,其结果是 [0 0 0 1 1 1 2 2 2],把它作为键进行分组就可以把数据分成每三行一组。而 agg(lambda x: x.mode()) 则是将各组的各列分别求众数,如第一组 time 的众数为 [0,1,2] 而 a 和 b 的众数分别是 [0.5] 和[-2.0]分别取最后 1 个众数 iloc[-1]即得到想要的结果。
八、值变化分组
值变化分组,指在有序的数据中,发生数据变化时就分出一个新组。举例如下:
source:https://stackoverflow.com/questions/41620920/groupby-conditional-sum-of-adjacent-rows-pandas
数据片段如下:
duration location user
0 10 house A
1 5 house A
2 5 gym A
3 4 gym B
4 10 shop B
5 4 gym B
6 6 gym B
按照 user 分组后,各组当 location 连续相同时对 duration 进行求和,location 变化时则重新求和。理想结果如下:
duration location user
15 house A
5 gym A
4 gym B
10 shop B
10 gym B
问题分析:location 列的顺序很重要,连续相同时可以视为一组,当变化时则重新分一组,如 user=B 时,第 4 行 (索引为 3) 的 location 为 [gym,shop,gym,gym], 不可以把其中的 3 个 gym 分到 1 组,而应该把第一个 gym 单独作为 1 组,shop 与 gym 不同,值发生了变化,把 shop 分到下一组,后面两个 gym 没有值变化,可以分到同一组,分组的结果为[[gym],[shop],[gym,gym]],所以这里不可以使用 df.groupby(['user','location']).duration.sum() 来计算结果,而是要想办法生成一个衍生列作为分组依据。
代码如下:
import pandas as pd
df = pd.DataFrame({'user' : ['A', 'A', 'A', 'B', 'B', 'B','B'],
'location' : ['house','house','gym','gym','shop','gym','gym'],
'duration':[10,5,5,4,10,4,6]})
derive = (df.location != df.location.shift()).cumsum()
res = df.groupby(['user', 'location', derive], as_index=False, sort=False)['duration'].sum()
print(res)
生成数据
创造衍生列
按照 user,location 和衍生列分组,对 duraton 求和
讨论:衍生列 derive 是当 location 与前者不同时进行累加,得到 [1 1 2 2 3 4 4]。然后按照 user,location 和该数列分组,再对 duration 求和。
九、条件变化分组
条件变化分组:指在有序的数据中,当满足某一条件时重新分组。举例如下:
source:https://stackoverflow.com/questions/62461647/choose-random-rows-in-pandas-datafram
数据片段如下:
ID code
333_c_132 x
333_c_132 n06
333_c_132 n36
333_c_132 n60
333_c_132 n72
333_c_132 n84
333_c_132 n96
333_c_132 n108
333_c_132 n120
999_c_133 x
999_c_133 n06
999_c_133 n12
999_c_133 n24
998_c_134 x
998_c_134 n06
998_c_134 n12
998_c_134 n18
998_c_134 n36
997_c_135 x
997_c_135 n06
997_c_135 n12
997_c_135 n24
997_c_135 n36
996_c_136 x
996_c_136 n06
996_c_136 n12
996_c_136 n18
996_c_136 n24
996_c_136 n36
995_c_137 x
希望从 code 列的每两个 x 中间随机取一行
理想结果形式如下:
333_c_132 n06
999_c_133 n12
998_c_134 n18
997_c_135 n36
996_c_136 n18
问题分析:取两个 x 之间的随机一条记录,可以转化成每当 code 等于 x 时开始新的一组,不等于 x 时分组不变,然后从该组中随机取一行。因此这里还是需要生成衍生列,把它作为键分组才能完成任务。
代码如下:
import pandas as pd
df = pd.read_csv("data.txt")
derive = df.code.eq('x').cumsum()
res=df[df.code.ne('x')].groupby(derive).apply(lambda x : x.sample(1))
res=res.reset_index(level=0, drop=True)
print(res)
生成衍生列
根据衍生列分组,使用 apply 结合 lambda 的方式随机抽样
重置索引
讨论:code.eq(x) 表示 code 等于 x 时为 True,其余为 False,cumsum()表示对其累加,生成的衍生列为 [1 1 1 1 1 1 1 1 1 2 2…],过滤掉等于 x 的列再根据该列进行分组并抽样即可。
思考:
前面所有的例子都是将原集合根据某个条件,将数据划分成若干个子集,且满足以下两点:
1)没有空子集
2)原集合的任何成员都属于且只属于某一个子集
我们称这种划分方式为完全划分。那么有没有不完全划分呢?
来看下面这几个例子
十、对位分组
对位分组,指先罗列出一个基准集合,然后将待分组集合成员的某个属性(字段或表达式)与基准集合成员比较,相同者则分到一个子集中,最后拆分出来的子集数量和基准集合成员数是相同的。对位分组有三个特点:
1)可能出现空子集(比如基准集合的某些成员在待分组集合中并不存在);
2)可能有待分组集合成员未被分到任何子集(比如有些不重要的成员未被列入基准集合);
3)每个成员最多只出现在一个子集中。
(一)出现空子集
公司统计各部门男女人数,如果某个部门没有男员工或者没有女员工,则将该部门的男员工人数或女员工人数填为 0。
问题分析:如果直接按照部门和性别分组,则如果某个部门没有女员工或没有男员工时,该部门将只被分成 1 组,就会丢失掉缺少的性别的统计信息,因此不可以直接 groupby([‘DEPT’,’GENDER’])。很容易想到的方案就是,先按部门分组,罗列出 [男, 女] 的基准集合,使用左连接 (left join) 的方式与各组连接,再对连接后的结果按照性别分组,最后汇总结果,这样就能保证分组的结果总会有 [男, 女] 了。
Python 代码
import pandas as pd
def align_group(g,l,by):
d = pd.DataFrame(l,columns=[by])
m = pd.merge(d,g,on=by,how='left')
return m.groupby(by,sort=False)
employee = pd.read_csv("Employees.csv")
l = ['M','F']
res = employee.groupby('DEPT').apply(lambda x:align_group(x, l, 'GENDER').apply(lambda s:s.EID.count()))
print(res)
函数,对位分组
生成对照的 dataframe
利用 merge 完成对位运算
分组
指定序列
按 DEPT 分组,再对各组使用函数对位分组,对 EID 进行计数
讨论:
自定义函数 align_group,使用 merge()函数完成罗列集合与待分组集合的 left join,再按 merge 的列进行分组。按部门分组后,使用 apply() 结合 lambda 表达式的方式对每组使用自定义函数对位分组,最后对 EID 列计数得到最终结果。(注意:这里不可以对 GENDER 计数,因为 merge 时 GENDER 的成员都被保留了,如果有空子集时,对它计数结果将是 1,而其他列(比如 EID), 在 left join 时会是空值,所以对 EID 计数结果是 0)。
(二)有待分组集合成员未被分到任何子集
按指定的部门 ['Administration', 'HR', 'Marketing', 'Sales'] 分组,只查询这几个部门的人数且部门先后顺序保持不变。
问题分析:与出现空子集的情况类似,此时也可以使用 left join 的方式,将不在预先罗列的集合成员排除掉,只保留罗列集合中的成员。
代码如下:
import pandas as pd
def align_group(g,l,by):
d = pd.DataFrame(l,columns=[by])
m = pd.merge(d,g,on=by,how='left')
return m.groupby(by,sort=False)
employee = pd.read_csv("Employees.csv")
sub_dept = ['Administration', 'HR', 'Marketing', 'Sales']
res = align_group(employee,sub_dept,'DEPT').apply(lambda x:x.EID.count())
print(res)
函数,对位分组
指定顺序的部门子集
使用对位分组函数分组,再对 EID 计数
讨论:Pandas 不直接支持对位分组的功能,因此完成起来成本就会比较高,而且使用 merge 函数也会导致运行效率低下。
十一、枚举分组
枚举分组:事先指定一组条件,将待分组集合的成员作为参数计算这批条件,条件成立者被划分到与该条件对应的一个子集中,结果集的子集和事先指定的条件一一对应。枚举分组的特点:允许集合成员重复出现在不同的子集中。
举例如下:
按在公司的工龄将员工分组统计每组的男女员工人数(分组条件重合时,列出所有满足条件的员工,分组的条件是 [工龄 <5 年,5 年 <= 工龄 <10 年,工龄 >=10 年,工龄 >=15 年])
问题分析:工龄 >=10 年和工龄 >=15 年两个条件有重复的区间,即工龄大于 15 年的员工,其工龄也一定大于 10 年,这时如果使用构造衍生列的方式来完成,将无法使同一个成员重复出现在两个分组中,因此需要考虑每个条件都分一次组,然后找出满足条件的组,最后再汇总。
import pandas as pd
import datetime
def eval_g(dd:dict,ss:str):
return eval(ss,dd)
emp_file = 'E:\\txt\\employee.txt'
emp_info = pd.read_csv(emp_file,sep='\t')
employed_list = ['Within five years','Five to ten years','More than ten years','Over fifteen years']
employed_str_list = ["(s<5)","(s>=5) & (s<10)","(s>=10)","(s>=15)"]
today = datetime.datetime.today().year
arr = pd.to_datetime(emp_info['HIREDATE'])
employed = today-arr.dt.year
emp_info['EMPLOYED']=employed
dd = {'s':emp_info['EMPLOYED']}
group_cond = []
for n in range(len(employed_str_list)):
emp_g = emp_info.groupby(eval_g(dd,employed_str_list[n]))
emp_g_index = [index for index in emp_g.size().index]
if True not in emp_g_index:
female_emp=0
male_emp=0
else:
group = emp_g.get_group(True)
sum_emp = len(group)
female_emp = len(group[group['GENDER']=='F'])
male_emp = sum_emp-female_emp
group_cond.append([employed_list[n],male_emp,female_emp])
group_df = pd.DataFrame(group_cond,columns=['EMPLOYED','MALE','FEMALE'])
print(group_df)
函数,字符串转表达式
分组条件
计算入职时间
循环分组条件
按分组条件分组
分组索引
如果没有满足条件的成员
男女员工数为 0
满足条件
获取分组
计算男女员工人数
汇总各个分组条件的计算结果
讨论:EMPLOYED 是根据入职时间 HIREDATE 新增加的一列,表示工龄。自定义函数 eval_g(),是把分组的条件转换成表达式,比如当条件是 s<5 时,eval_g(dd,ss)的表达式就是 emp_info['EMPLOYED']<5,根据这个衍生列来对数据分组。对分组条件进行循环,按该衍生列分成两组,get_group(True) 表示取满足条件的组,最后把所有满足条件的结果使用 concat() 函数汇总。
总结
Python 在进行分组处理时,多数情况可以比较优雅的处理,但在处理有序分组时,如值变化分组、条件变化分组时则需要自己想办法生成满足分组条件的衍生列,略显麻烦。对位分组和枚举分组的两种情况更是糟糕,需要自己想办法去绕,要么使用 merge 运算,要么多次分组,使分组的成本变得很高,这样看来,Pandas 的分组运算还有其局限性。
对于分组运算,相比之下,esProc SPL 处理的更完善。 esProc 是专业的数据计算引擎,SPL 提供了丰富的分组运算,可以方便的完成上述任务,代码风格的一致程度也更好。
两个分组运算函数 groups()和 group(),分别实现分组聚合和分组子集,可以比 Python 更简洁地解决前面六个常规分组问题:
分组子集运算
对于这六个简单分组计算,Python 的分组计算方法同样方便。但涉及了很多其他函数,如 agg,transform,apply,lambda 表达式甚至是自定义函数等等,代码风格差别比较大。而 SPL 则基本保持了 groups(x;y) 或者是 group(x).(y) 这样统一的代码风格。
对于问题七、八、九,Python 就略显烦琐,需想办法生成衍生列,而 SPL 本身基于有序集合设计,提供了有序分组的选项,仍可以优雅的保持简单运算时的代码风格。
问题SPL代码简单说明
根据分组后直接聚合还是分组后针对子集计算,灵活选择 groups 和 group 函数。
最后两个问题,对位分组和枚举分组,确实有点难为 Python 了,不过不管是使用 merge 函数绕还是多次分组,总算是完成了任务。而 SPL 提供了专门的对位分组函数 align()和枚举分组函数 enum(),可以继续优雅。
有成员被分到不同子集
需要提到的是,Python 还有一个致命缺点——大数据(无法一次性读入内存)分组,它涉及到外存读写和 hash 分组,对于非专业的程序员来说,使用 Python 完成这个任务几乎是不可能的。有兴趣可以参考以下文章:
这里介绍了 Python 处理大数据存在的问题(包括大数据分组),也简单介绍了 esProc SPL 中的游标系统,其中 group 和 groupx() 函数仍然可以优雅的完成大数据分组任务。