天天看點

深入了解 Pandas 的 groupby 函數

作者:漸變十二圓

最近在學習 Pandas,在處理資料時,經常需要對資料的某些字段進行分組分析,這就需要用到 groupby 函數,這篇文章做一個詳細記錄
Pandas 版本 1.4.3

Pandas 中的 groupby 函數先将 DataFrame 或 Series 按照關注字段進行拆分,将相同屬性劃分為一組,然後可以對拆分後的各組執行相應的轉換操作,最後傳回彙總轉換後的各組結果

一、基本用法

先初始化一些資料,友善示範

import pandas as pd

df = pd.DataFrame({
            'name': ['香蕉', '菠菜', '糯米', '糙米', '絲瓜', '冬瓜', '柑橘', '蘋果', '橄榄油'],
            'category': ['水果', '蔬菜', '米面', '米面', '蔬菜', '蔬菜', '水果', '水果', '糧油'],
            'price': [3.5, 6, 2.8, 9, 3, 2.5, 3.2, 8, 18],
            'count': [2, 1, 3, 6, 4, 8, 5, 3, 2]
        })           
深入了解 Pandas 的 groupby 函數

按 category 分組

grouped = df.groupby('category')
print(type(grouped))
print(grouped)           

輸出結果

<class 'pandas.core.groupby.generic.DataFrameGroupBy'>
<pandas.core.groupby.generic.DataFrameGroupBy object at 0x127112df0>           

grouped 的類型是 DataFrameGroupBy,直接嘗試輸出,列印是記憶體位址,不太直覺,這裡寫一個函數來展示(可以這麼寫的原理,後面會介紹)

def view_group(the_pd_group):
    for name, group in the_pd_group:
        print(f'group name: {name}')
        print('-' * 30)
        print(group)
        print('=' * 30, '\n')
view_group(grouped)           

輸出結果

group name: 水果
------------------------------
    name  category  price  count
0   香蕉       水果    3.5      2
6   柑橘       水果    3.2      5
7   蘋果       水果    8.0      3
============================== 
group name: 米面
------------------------------
    name  category  price  count
2   糯米       米面    2.8      3
3   糙米       米面    9.0      6
============================== 
group name: 糧油
------------------------------
   name    category  price  count
8  橄榄油       糧油   18.0      2
============================== 
group name: 蔬菜
------------------------------
    name  category  price  count
1   菠菜       蔬菜    6.0      1
4   絲瓜       蔬菜    3.0      4
5   冬瓜       蔬菜    2.5      8
==============================            

二、參數源碼探析

接下來看一下源碼中的方法定義 DataFrame 的 groupby

def groupby(
        self,
        by=None,
        axis: Axis = 0,
        level: Level | None = None,
        as_index: bool = True,
        sort: bool = True,
        group_keys: bool = True,
        squeeze: bool | lib.NoDefault = no_default,
        observed: bool = False,
        dropna: bool = True,
    ) -> DataFrameGroupBy:
    pass           

Series 的 groupby

def groupby(
        self,
        by=None,
        axis=0,
        level=None,
        as_index: bool = True,
        sort: bool = True,
        group_keys: bool = True,
        squeeze: bool | lib.NoDefault = no_default,
        observed: bool = False,
        dropna: bool = True,
    ) -> SeriesGroupBy:
    pass           

Series 的 groupby 函數操作與 DataFrame 類似,這篇文章隻以 DataFrame 作為示例

入參

by

再來回憶一下基本用法裡的寫法

grouped = df.groupby('category')           

這裡傳入的 category 就是第 1 個參數 by,表示要按照什麼進行分組,根據官方文檔介紹,by 可以是 mapping, function, label, list of labels 中的一種,這裡是用的 label,也就是說,還可以像下面這樣寫

  1. label 清單
grouped = df.groupby(['category'])           
  1. mapping

這種方式需要按 DataFrame 的 index 進行映射,這裡把水果和蔬菜劃分到大組蔬菜水果,米面和糧油劃分到大組米面糧油

category_dict = {'水果': '蔬菜水果', '蔬菜': '蔬菜水果', '米面': '米面糧油', '糧油': '米面糧油'}
the_map = {}
for i in range(len(df.index)):
    the_map[i] = category_dict[df.iloc[i]['category']]
grouped = df.groupby(the_map)
view_group(grouped)           

輸出結果如下

group name: 米面糧油
------------------------------
    name  category  price  count
2   糯米       米面    2.8      3
3   糙米       米面    9.0      6
8  橄榄油      糧油   18.0      2
============================== 

group name: 蔬菜水果
------------------------------
    name  category  price  count
0   香蕉       水果    3.5      2
1   菠菜       蔬菜    6.0      1
4   絲瓜       蔬菜    3.0      4
5   冬瓜       蔬菜    2.5      8
6   柑橘       水果    3.2      5
7   蘋果       水果    8.0      3
==============================            
  1. function

這種方式下,自定義函數的入參也是 DataFrame 的 index,輸出結果與 mapping 的例子相同

category_dict = {'水果': '蔬菜水果', '蔬菜': '蔬菜水果', '米面': '米面糧油', '糧油': '米面糧油'}

def to_big_category(the_idx):
    return category_dict[df.iloc[the_idx]['category']]
grouped = df.groupby(to_big_category)
view_group(grouped)           

axis

axis 表示以哪個軸作為分組的切分依據

0 - 等價于index, 表示按行切分,預設

1 - 等價于columns,表示按列切分

這裡看一下按列切分的示例

def group_columns(column_name: str):
    if column_name in ['name', 'category']:
        return 'Group 1'
    else:
        return 'Group 2'
# 等價寫法 grouped = df.head(3).groupby(group_columns, axis='columns')
grouped = df.head(3).groupby(group_columns, axis=1)
view_group(grouped)           

輸出結果如下

group name: Group 1
------------------------------
    name  category
0   香蕉       水果
1   菠菜       蔬菜
2   糯米       米面
============================== 

group name: Group 2
------------------------------
   price  count
0    3.5      2
1    6.0      1
2    2.8      3
==============================           

相當于把表從垂直方向上切開,左半部分為 Group 1,右半部分為 Group 2

level

當 axis 是 MultiIndex (層級結構)時,按特定的 level 進行分組,注意這裡的 level 是 int 類型,從 0 開始,0 表示第 1 層,以此類推

構造另一組帶 MultiIndex 的測試資料

the_arrays = [['A', 'A', 'A', 'B', 'A', 'A', 'A', 'B', 'A', 'A'],
              ['蔬菜水果', '蔬菜水果', '米面糧油', '休閑食品', '米面糧油', '蔬菜水果', '蔬菜水果', '休閑食品', '蔬菜水果', '米面糧油'],
              ['水果', '蔬菜', '米面', '糖果', '米面', '蔬菜', '蔬菜', '餅幹', '水果', '糧油']]
the_index = pd.MultiIndex.from_arrays(arrays=the_arrays, names=['one ', 'two', 'three'])
df_2 = pd.DataFrame(data=[3.5, 6, 2.8, 4, 9, 3, 2.5, 3.2, 8, 18], index=the_index, columns=['price'])
print(df_2)           

輸出結果如下

price
one  two    three       
A    蔬菜水果 水果       3.5
             蔬菜       6.0
     米面糧油 米面       2.8
B    休閑食品 糖果       4.0
A    米面糧油 米面       9.0
     蔬菜水果 蔬菜       3.0
             蔬菜       2.5
B    休閑食品 餅幹       3.2
A    蔬菜水果 水果       8.0
     米面糧油 糧油      18.0           

1. 按第 3 層分組

grouped = df_2.groupby(level=2)
view_group(grouped)           

輸出結果如下

group name: 水果
------------------------------
                      price
one  two    three       
A    蔬菜水果 水果       3.5
             水果       8.0
============================== 

group name: 米面
------------------------------
                     price
one  two    three       
A    米面糧油 米面       2.8
             米面       9.0
============================== 

group name: 糧油
------------------------------
                      price
one  two    three       
A    米面糧油 糧油      18.0
============================== 

group name: 糖果
------------------------------
                      price
one  two    three       
B    休閑食品 糖果       4.0
============================== 

group name: 蔬菜
------------------------------
                     price
one  two    three       
A    蔬菜水果 蔬菜       6.0
             蔬菜       3.0
             蔬菜       2.5
============================== 

group name: 餅幹
------------------------------
                      price
one  two    three       
B    休閑食品 餅幹       3.2
==============================           

共 6 個分組

2. 按第 1, 2 層分組

grouped = df_2.groupby(level=[0, 1])
view_group(grouped)           

輸出結果如下

group name: ('A', '米面糧油')
------------------------------
                      price
one  two    three       
A    米面糧油 米面       2.8
             米面       9.0
             糧油      18.0
============================== 

group name: ('A', '蔬菜水果')
------------------------------
                      price
one  two    three       
A    蔬菜水果 水果       3.5
             蔬菜       6.0
             蔬菜       3.0
             蔬菜       2.5
             水果       8.0
============================== 

group name: ('B', '休閑食品')
------------------------------
                      price
one  two    three       
B    休閑食品 糖果       4.0
             餅幹       3.2
==============================            

共 3 個分組,可以看到,分組名稱變成了元組

as_index

bool 類型,預設值為 True。對于聚合輸出,傳回對象以分組名作為索引

grouped = self.df.groupby('category', as_index=True)
print(grouped.sum())           

as_index 為 True 的輸出結果如下

price  count
category              
水果         14.7     10
米面         11.8      9
糧油         18.0      2
蔬菜         11.5     13           
grouped = self.df.groupby('category', as_index=False)
print(grouped.sum())           

as_index 為 False 的輸出結果如下,與 SQL 的 groupby 輸出風格相似

category  price  count
0       水果   14.7     10
1       米面   11.8      9
2       糧油   18.0      2
3       蔬菜   11.5     13           

sort

bool 類型,預設為 True。是否對分組名進行排序,關閉自動排序可以提高性能。注意:對分組名排序并不影響分組内的順序

group_keys

bool 類型,預設為 True

如果為 True,調用 apply 時,将分組的 keys 添加到索引中

squeeze

1.1.0 版本已廢棄,不解釋

observed

bool 類型,預設值為 False

僅适用于任何 groupers 是分類(Categoricals)的

如果為 True,僅顯示分類分組的觀察值; 如果為 False ,顯示分類分組的所有值

dropna

bool 類型,預設值為 True,1.1.0 版本新增參數

如果為 True,且分組的 keys 中包含 NA 值,則 NA 值連同行(axis=0) / 列(axis=1)将被删除

如果為 False,NA 值也被視為分組的 keys,不做處理

傳回值

DateFrame 的 gropuby 函數,傳回類型是 DataFrameGroupBy,而 Series 的 groupby 函數,傳回類型是 SeriesGroupBy

檢視源碼後發現他們都繼承了 BaseGroupBy,繼承關系如圖所示

深入了解 Pandas 的 groupby 函數

BaseGroupBy 類中有一個 grouper 屬性,是 ops.BaseGrouper 類型,但 BaseGroupBy 類沒有 init 方法,是以進入 GroupBy 類,該類重寫了父類的 grouper 屬性,在 init 方法中調用了 grouper.py 的 get_grouper,下面是抽取出來的僞代碼

groupby.py 檔案

class GroupBy(BaseGroupBy[NDFrameT]):
	grouper: ops.BaseGrouper
	
	def __init__(self, ...):
		# ...
		if grouper is None:
			from pandas.core.groupby.grouper import get_grouper
			grouper, exclusions, obj = get_grouper(...)           

grouper.py 檔案

def get_grouper(...) -> tuple[ops.BaseGrouper, frozenset[Hashable], NDFrameT]:
	# ...
	# create the internals grouper
    grouper = ops.BaseGrouper(
        group_axis, groupings, sort=sort, mutated=mutated, dropna=dropna
    )
	return grouper, frozenset(exclusions), obj

class Grouping:
	"""
	obj : DataFrame or Series
	"""
	def __init__(
        self,
        index: Index,
        grouper=None,
        obj: NDFrame | None = None,
        level=None,
        sort: bool = True,
        observed: bool = False,
        in_axis: bool = False,
        dropna: bool = True,
    ):
    	pass           

ops.py 檔案

class BaseGrouper:
    """
    This is an internal Grouper class, which actually holds
    the generated groups
    
    ......
    """
    def __init__(self, axis: Index, groupings: Sequence[grouper.Grouping], ...):
    	# ...
    	self._groupings: list[grouper.Grouping] = list(groupings)
    
    @property
    def groupings(self) -> list[grouper.Grouping]:
        return self._groupings           

BaseGrouper 中包含了最終生成的分組資訊,是一個 list,其中的元素類型為 grouper.Grouping,每個分組對應一個 Grouping,而 Grouping 中的 obj 對象為分組後的 DataFrame 或者 Series

在第一部分寫了一個函數來展示 groupby 傳回的對象,這裡再來探究一下原理,對于可疊代對象,會實作 iter() 方法,先定位到 BaseGroupBy 的對應方法

class BaseGroupBy:
	grouper: ops.BaseGrouper
	
	@final
    def __iter__(self) -> Iterator[tuple[Hashable, NDFrameT]]:
        return self.grouper.get_iterator(self._selected_obj, axis=self.axis)           

接下來進入 BaseGrouper 類中

class BaseGrouper:
    def get_iterator(
        self, data: NDFrameT, axis: int = 0
    ) -> Iterator[tuple[Hashable, NDFrameT]]:
        splitter = self._get_splitter(data, axis=axis)
        keys = self.group_keys_seq
        for key, group in zip(keys, splitter):
            yield key, group.__finalize__(data, method="groupby")           

Debug 模式進入 group.finalize() 方法,發現傳回的确實是 DataFrame 對象

深入了解 Pandas 的 groupby 函數

三、4 大函數

有了上面的基礎,接下來再看 groupby 之後的處理函數,就簡單多了

agg

聚合操作是 groupby 後最常見的操作,常用來做資料分析

比如,要檢視不同 category 分組的最大值,以下三種寫法都可以實作,并且 grouped.aggregate 和 grouped.agg 完全等價,因為在 SelectionMixin 類中有這樣的定義:agg = aggregate

深入了解 Pandas 的 groupby 函數

但是要聚合多個字段時,就隻能用 aggregate 或者 agg 了,比如要擷取不同 category 分組下 price 最大,count 最小的記錄

深入了解 Pandas 的 groupby 函數

還可以結合 numpy 裡的聚合函數

import numpy as np
grouped.agg({'price': np.max, 'count': np.min})           

常見的聚合函數如下

聚合函數 功能
max 最大值
mean 平均值
median 中位數
min 最小值
sum 求和
std 标準差
var 方差
count 計數

其中,count 在 numpy 中對應的調用方式為 np.size

transform

現在需要新增一列 price_mean,展示每個分組的平均價格

transform 函數剛好可以實作這個功能,在指定分組上産生一個與原 df 相同索引的 DataFrame,傳回與原對象有相同索引且已填充了轉換後的值的 DataFrame,然後可以把轉換結果新增到原來的 DataFrame 上

示例代碼如下

grouped = df.groupby('category', sort=False)
df['price_mean'] = grouped['price'].transform('mean')
print(df)           

輸出結果如下

深入了解 Pandas 的 groupby 函數

apply

現在需要擷取各個分組下價格最高的資料,調用 apply 可以實作這個功能,apply 可以傳入任意自定義的函數,實作複雜的資料操作

from pandas import DataFrame
grouped = df.groupby('category', as_index=False, sort=False)

def get_max_one(the_df: DataFrame):
    sort_df = the_df.sort_values(by='price', ascending=True)
    return sort_df.iloc[-1, :]
max_price_df = grouped.apply(get_max_one)
max_price_df           

輸出結果如下

深入了解 Pandas 的 groupby 函數

filter

filter 函數可以對分組後資料做進一步篩選,該函數在每一個分組内,根據篩選函數排除不滿足條件的資料并傳回一個新的 DataFrame

假設現在要把平均價格低于 4 的分組排除掉,根據 transform 小節的資料,會把蔬菜分類過濾掉

grouped = df.groupby('category', as_index=False, sort=False)
filtered = grouped.filter(lambda sub_df: sub_df['price'].mean() > 4)
print(filtered)           

輸出結果如下

深入了解 Pandas 的 groupby 函數

四、總結

groupby 的過程就是将原有的 DataFrame/Series 按照 groupby 的字段,劃分為若幹個分組 DataFrame/Series,分成多少個組就有多少個分組 DataFrame/Series。是以,在 groupby 之後的一系列操作(如 agg、apply 等),均是基于子 DataFrame/Series 的操作。了解了這點,就了解了 Pandas 中 groupby 操作的主要原理

五、參考文檔

Pandas 官網關于 pandas.DateFrame.groupby 的介紹

Pandas 官網關于 pandas.Series.groupby 的介紹

繼續閱讀