在日常数据分析时,经常会遇到需要按列分组 (groupby) 的任务,如计算某公司各部门的人数,计算各部门男女平均工资,计算不同年代的员工的平均工资等等。在进行这类运算时,Pandas 提供了 groupby 函数,大多数问题它都可以解决,但有一些问题使用 groupby 函数会略显麻烦,下面我们就这些问题展开细致的讨论。

groupby 是 pandas 中非常重要的一个函数, 主要用于数据分类和聚合计算. 其思想是“split-apply-combine”(拆分 - 应用 - 合并),如下图:

分组原理图

一、单列分组聚合

单列分组聚合是指把某一列作为键进行分组,然后对各组进行聚合运算。

它是上述分组原理的最简单应用,比如根据员工信息数据,计算各部门员工数。

问题分析:要计算各部门员工数,首先把部门作为键进行分组,然后对各组成员进行计数。

部分员工信息数据如下:

SURNAME GENDER STATE BIRTHDAY HIREDATE SALARY Rebecca Moore California 1974/11/20 2005/3/11 Ashley Wilson New York 1980/7/19 2008/3/16 Finance 11000 Rachel Johnson New   Mexico 1970/12/17 2010/12/1 Sales Emily Smith Texas 1985/3/7 2006/8/15 Ashley Smith Texas 1975/5/13 2004/7/30 16000

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 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 代码

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 代码

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:

数据片段如下:

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 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:

数据片段如下:

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:

数据片段如下:

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 时分组不变,然后从该组中随机取一行。因此这里还是需要生成衍生列,把它作为键分组才能完成任务。

代码如下:

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 代码

m =   pd.merge(d,g,>

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 的方式,将不在预先罗列的集合成员排除掉,只保留罗列集合中的成员。

代码如下:

m =   pd.merge(d,g,>

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 年,这时如果使用构造衍生列的方式来完成,将无法使同一个成员重复出现在两个分组中,因此需要考虑每个条件都分一次组,然后找出满足条件的组,最后再汇总。

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)

函数,字符串转表达式

汇总各个分组条件的计算结果

讨论: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 更简洁地解决前面六个常规分组问题:

SPL代码 A.groups(DEPT;count(~):NUM) A是数据表,按 DEPT 分组,count() 计数 A.groups(DEPT,GENDER;avg(SALARY):AVG_SALARY) 按 DEPT,GENDER 分组,avg() 平均

A.groups((year(BIRTHDAY)-

1900)\10:years;avg(SALARY):AVG_SALARY)

(year(BIRTHDAY)-1900)\10命名为 years,并分组

A.groups(DEPT;count(EID):NUM,avg(SALARY):AVG_SAL

多列单聚合

A.groups(DEPT;count(SALARY):NUM,avg(SALARY):AVG_

SALARY)

单列多聚合

A.groups(DEPT;count(SALARY):NUM,avg(SALARY):AVG_

SALARY,max(age(BIRTHDAY)):MAX_AVG)

多列多聚合

B=A.derive(AVG_SALARY)

>B.group(DEPT).((a=~.avg(SALARY),~.run(AVG_SALARY

=a)))

修改为分组的聚合值

A.group(DEPT).conj(~.sort(HIREDATE))

A.group(DEPT;(ma=~.minp(BIRTHDAY),mi=~.maxp(BIR

THDAY),ma.SALARY-mi.SALARY):SALARY_DIF)

分组子集运算

对于这六个简单分组计算,Python 的分组计算方法同样方便。但涉及了很多其他函数,如 agg,transform,apply,lambda 表达式甚至是自定义函数等等,代码风格差别比较大。而 SPL 则基本保持了 groups(x;y) 或者是 group(x).(y) 这样统一的代码风格。

对于问题七、八、九,Python 就略显烦琐,需想办法生成衍生列,而 SPL 本身基于有序集合设计,提供了有序分组的选项,仍可以优雅的保持简单运算时的代码风格。

SPL代码 A.groups@n((#-1)\3;y) 每三行分 1 组,y 是聚合运算表达式 A.groups@o(user,location;y) @o选项:值变化分组 A.group@i(code==”x”).(y) @i选项:条件变化分组

根据分组后直接聚合还是分组后针对子集计算,灵活选择 groups 和 group 函数。

最后两个问题,对位分组和枚举分组,确实有点难为 Python 了,不过不管是使用 merge 函数绕还是多次分组,总算是完成了任务。而 SPL 提供了专门的对位分组函数 align()和枚举分组函数 enum(),可以继续优雅。

SPL分组处理

s=[“M”,”F”]

A.group(DEPT).(~.align@a(s,GENDER).(y))

可能出现空子集

s=[“Administration”,   “HR”, “Marketing”, “Sales”]

A.align@a(s,DEPT).(y)

有待分组集合成员未被分到任何子集

c=[“?<5”,”?>=5   && ?<10”,”?>=10”,”?>=15”]

A.enum(c, EMPLOYED)

有成员被分到不同子集

需要提到的是,Python 还有一个致命缺点——大数据(无法一次性读入内存)分组,它涉及到外存读写和 hash 分组,对于非专业的程序员来说,使用 Python 完成这个任务几乎是不可能的。有兴趣可以参考以下文章:

这里介绍了 Python 处理大数据存在的问题(包括大数据分组),也简单介绍了 esProc SPL 中的游标系统,其中 group 和 groupx() 函数仍然可以优雅的完成大数据分组任务。

广播电视节目制作经营许可证(京) 字第1234号 中国互联网协会会员