Pandas分组聚合:数据分析核心技能,从原理到实战
1. 项目概述为什么“分组聚合”是数据分析的基石如果你用过Excel的数据透视表那你对“分组聚合”这个概念就不会陌生。简单来说就是把一堆杂乱的数据按照某个或某几个特征比如“城市”、“产品类别”、“月份”分成不同的组然后对每个组内的数据进行统计计算比如求和、平均、计数。在Python的数据分析领域Pandas库的groupby操作就是实现这一功能的瑞士军刀其强大和灵活程度远超Excel。我见过太多新手数据分析师拿到数据后第一反应就是写循环一行行去判断、累加代码冗长且效率低下。一旦掌握了Pandas的分组聚合你会发现处理这类问题的代码可以简洁到令人发指并且性能提升几个数量级。无论是分析销售数据看各区域业绩还是处理用户行为数据计算人均访问时长分组聚合都是你绕不开的核心技能。这个“第1关”闯过去你的数据分析之路才算真正入门。2. 核心概念与groupby机制深度解析2.1 “拆分-应用-合并”三部曲Pandas的groupby操作遵循一个经典的三步流程“拆分Split-应用Apply-合并Combine”。理解这个模型是灵活运用groupby的关键。拆分Split根据你提供的一个或多个“键”Key将原始的DataFrame拆分成若干个独立的子组Group。这个“键”可以是列名、数组、或一个函数。例如一个包含“部门”和“销售额”的表格按“部门”拆分就会得到市场部、技术部、销售部等多个子数据集。应用Apply对每个子组独立地执行一个操作。这个操作可以是聚合Aggregation计算组的汇总统计量如sum()mean()count()std()等。这是最常用的操作输出结果的行数等于组的数量。转换Transformation对组内的每个元素进行计算返回一个与原始组形状相同的对象。例如计算每个组内的“标准化”值组内值减去组均值。结果行数与原始数据相同。过滤Filtration根据组的汇总统计量丢弃某些组。例如过滤出成员数量大于10的组。结果行数可能减少但组内数据保持原样。合并Combine将应用步骤产生的结果按照分组键重新组合成一个新的数据结构通常是DataFrame或Series并呈现给用户。这个模型的美妙之处在于它将复杂的逻辑分解为清晰的步骤让你可以像搭积木一样组合不同的操作。2.2GroupBy对象延迟计算的魔力当你执行df.groupby(‘key’)时返回的并不是一个直接的结果而是一个DataFrameGroupBy对象。你可以把它理解为一个“视图”或“蓝图”它记录了如何对数据进行分组但并没有立即进行计算。这是一种“延迟计算”Lazy Evaluation策略。注意很多初学者会在这里困惑打印groupby对象看不到具体数据。这是正常的因为它只是一个分组方案。只有当你对它调用聚合方法如.sum()或进行迭代时真正的计算才会发生。这种设计的优势在于高效避免了不必要的中间计算只有在你需要结果时才执行。灵活你可以在同一个GroupBy对象上链式调用多个操作Pandas会优化执行过程。2.3 单列分组与多列分组分组可以基于一列也可以基于多列形成多级索引Hierarchical Index这对于分析多维数据至关重要。单列分组df.groupby(‘Department’)[‘Sales’].sum()。这是最常见的形式。多列分组df.groupby([‘Year’, ‘Quarter’])[‘Revenue’].mean()。这会先按‘Year’分然后在每个‘Year’内再按‘Quarter’分结果是一个具有(Year, Quarter)两级索引的Series。这对于时间序列和交叉分析非常有用。3. 聚合操作实战从基础到高阶3.1 内置聚合方法速查与应用Pandas提供了一系列开箱即用的聚合函数以下是最常用的一些方法描述典型应用场景count()非NA值的数量统计每个组的有效数据条数sum()求和计算各区域销售总额、总访问量mean()算术平均数计算平均客单价、平均响应时间median()中位数分析收入分布避免极端值影响std(),var()标准差、方差衡量组内数据的离散程度min(),max()最小值、最大值找出每个品类的最低价和最高价prod()乘积计算复合增长率时可能用到first(),last()第一个、最后一个非NA值获取时间序列中每个周期的起始和结束值size()组的大小包含NA与count()区别在于size计入所有行基础用法示例import pandas as pd import numpy as np # 示例数据 data { ‘City‘: [‘北京‘, ‘上海‘, ‘北京‘, ‘广州‘, ‘上海‘, ‘北京‘, ‘广州‘], ‘Product‘: [‘A‘, ‘B‘, ‘A‘, ‘A‘, ‘B‘, ‘B‘, ‘A‘], ‘Sales‘: [100, 200, 150, 90, 220, 130, 80], ‘Profit‘: [20, 50, 30, 15, 60, 35, 10] } df pd.DataFrame(data) # 单列分组单列聚合 city_sales df.groupby(‘City‘)[‘Sales‘].sum() print(“各城市总销售额“) print(city_sales) # 单列分组多列聚合 city_stats df.groupby(‘City‘)[[‘Sales‘, ‘Profit‘]].agg([‘sum‘, ‘mean‘, ‘count‘]) print(“\n各城市销售与利润统计“) print(city_stats)这个city_stats结果会是一个具有多级列索引的DataFrame第一级是原始列名‘Sales‘ ‘Profit‘第二级是聚合函数名‘sum‘ ‘mean‘ ‘count‘。这种结构非常便于进行多维度的对比分析。3.2 使用agg()或aggregate()进行灵活聚合agg()方法是分组聚合的“王牌”它允许你进行高度定制化的聚合操作。1. 对不同的列应用不同的聚合函数这是agg()最强大的功能之一。你传入一个字典键是列名值可以是单个聚合函数、函数名的字符串或函数列表。# 对不同列使用不同聚合 custom_agg df.groupby(‘City‘).agg({ ‘Sales‘: ‘sum‘, # 对Sales列求和 ‘Profit‘: [‘mean‘, ‘std‘], # 对Profit列求均值和标准差 ‘Product‘: ‘count‘ # 对Product列计数非NA }) print(custom_agg)2. 对同一列应用多个聚合函数# 对Sales列同时进行多种聚合 sales_detail df.groupby(‘City‘)[‘Sales‘].agg([‘sum‘, ‘mean‘, ‘max‘, lambda x: x.max() - x.min()]) # 重命名自定义的lambda函数列 sales_detail sales_detail.rename(columns{‘lambda_0‘: ‘range‘}) print(sales_detail)3. 使用自定义函数你可以传入任何接收一个Series并返回一个标量值的函数。# 定义一个计算变异系数的函数 def coefficient_of_variation(series): “““计算变异系数标准差/均值用于比较不同均值数据的离散程度。”“” return series.std() / series.mean() cv_result df.groupby(‘City‘)[‘Sales‘].agg([‘mean‘, ‘std‘, coefficient_of_variation]) print(cv_result)实操心得在使用自定义函数时尤其是通过lambda表达式结果列的命名会变得不友好如lambda_0。一个好的习惯是要么在agg中使用具名函数要么在聚合完成后立即使用.rename()方法重命名列这能让你的结果DataFrame更清晰易懂。3.3 多级索引结果的处理技巧经过复杂分组聚合后你经常会得到一个具有多级索引行或列的DataFrame。处理它们需要一些技巧。重置索引使用.reset_index()将多层行索引变回普通的列。这是最常用的操作能让数据变“平”方便后续处理或导出。multi_index_df df.groupby([‘City‘, ‘Product‘])[‘Sales‘].sum() flat_df multi_index_df.reset_index() print(flat_df)交换索引层级对于行多级索引可以使用.swaplevel()交换内外层索引的顺序。索引排序使用.sort_index()对多级索引进行排序让数据呈现更有序。访问特定层级使用.xs()方法可以交叉选择特定层级索引的数据。4. 分组后的转换与过滤4.1 转换操作transformtransform与agg的关键区别在于它返回一个与原始分组对象形状相同的对象。它通常用于组内的标准化、填充或基于组的计算。典型场景计算组内排名或Z-Score# 计算每个城市内部销售额的Z-Score标准分数 df[‘Sales_Z‘] df.groupby(‘City‘)[‘Sales‘].transform( lambda x: (x - x.mean()) / x.std() ) print(df[[‘City‘, ‘Sales‘, ‘Sales_Z‘]])这里transform接收一个函数该函数应用于每个城市分组下的‘Sales‘ Series计算其Z-Score并将结果按照原始索引对齐后赋值回原DataFrame的新列‘Sales_Z‘。原DataFrame的行数没有改变。另一个场景用组均值填充缺失值# 假设Profit有缺失值 df.loc[2, ‘Profit‘] np.nan df[‘Profit_Filled‘] df.groupby(‘City‘)[‘Profit‘].transform( lambda x: x.fillna(x.mean()) )4.2 过滤操作filterfilter用于根据组的属性如大小、汇总统计量来筛选组。它接收一个返回布尔值的函数该函数以整个组一个小的DataFrame作为参数。典型场景筛选出记录条数大于阈值的组# 只保留至少有2条记录的城市数据 filtered_df df.groupby(‘City‘).filter(lambda group: len(group) 2) print(“过滤后的数据“) print(filtered_df)注意filter返回的是过滤掉整个组后的原始数据行而不是聚合后的摘要。在上例中如果一个城市只有一条记录那么这条记录会被完全移除。复杂场景筛选出销售额波动大的组# 筛选出销售额标准差大于50的城市组 filtered_by_std df.groupby(‘City‘).filter(lambda g: g[‘Sales‘].std() 50)5. 高级分组技巧与性能优化5.1 按字典或Series进行分组有时分组键并不直接存在于列中或者你想根据一个映射关系来分组。这时可以传入一个与DataFrame行数相同的Series或字典。# 假设我们有一个将产品映射到大类的字典 product_category {‘A‘: ‘Electronics‘, ‘B‘: ‘Furniture‘} # 创建一个映射Series df[‘Category‘] df[‘Product‘].map(product_category) # 或者直接在groupby中使用映射 category_sales df.groupby(df[‘Product‘].map(product_category))[‘Sales‘].sum() print(category_sales)5.2 按函数分组分组键可以是一个函数该函数会作用在索引上根据函数返回值进行分组。这对于时间序列数据按特定周期分组非常有用。# 创建一个带时间索引的示例数据 date_rng pd.date_range(start‘2023-01-01‘, end‘2023-01-10‘, freq‘D‘) ts_df pd.DataFrame({‘value‘: np.random.randn(len(date_rng))}, indexdate_rng) # 按星期几分组0Monday, 6Sunday weekly_group ts_df.groupby(lambda x: x.weekday()).mean() print(weekly_group)5.3 迭代GroupBy对象虽然大多数时候我们直接使用聚合方法但迭代GroupBy对象可以让你更细致地控制每个组。for name, group in df.groupby(‘City‘): print(f“\n城市 {name}“) print(group) # 在这里可以对每个group进行任何复杂的操作name是分组键的值group是对应的子DataFrame。5.4 性能优化要点避免在循环中分组如果你需要对同一数据框按不同键多次分组最好先复制数据或重新组织逻辑而不是在循环内反复调用groupby。使用内置函数sum(),mean()等内置的Cython优化函数远比使用agg(lambda x: x.sum())快。谨慎使用applygroupby().apply(func)非常灵活但性能开销较大因为func是按组在Python层面调用的。如果func本身可以用向量化操作或内置聚合实现应优先选择后者。关注数据类型确保用于分组的列是类别型category或简单的对象类型如字符串数值型数据作为分组键通常效率较低且不符合逻辑。6. 常见问题与排查技巧实录在实际工作中你肯定会遇到各种奇怪的问题。下面是我踩过的一些坑和解决方案。6.1 分组键包含缺失值NaNNaN在分组时会被自动排除。这意味着如果某行的分组键是NaN它不会出现在任何组中相应的数据在聚合结果中也会消失。df_nan pd.DataFrame({‘key‘: [‘A‘, ‘B‘, np.nan, ‘A‘], ‘value‘: [1, 2, 3, 4]}) print(df_nan.groupby(‘key‘).sum()) # 输出结果只有A和B组值为NaN的那一行value3没有被计入。解决方案在分组前需要决定如何处理缺失的分组键。通常有两种选择填充缺失值df[‘key‘].fillna(‘Unknown‘, inplaceTrue)删除该行df.dropna(subset[‘key‘], inplaceTrue)6.2 聚合结果列名混乱当使用agg()进行复杂聚合特别是混合了内置函数字符串、自定义函数和lambda时生成的列名可能难以理解。result df.groupby(‘City‘).agg({ ‘Sales‘: [‘sum‘, lambda x: x.quantile(0.9)] }) print(result.columns) # 可能是MultiIndex, 包含一个难懂的‘lambda_0‘解决方案在agg中使用元组来指定列名result df.groupby(‘City‘).agg( total_sales(‘Sales‘, ‘sum‘), sales_p90(‘Sales‘, lambda x: x.quantile(0.9)) )这是Pandas较新版本提供的更清晰的语法。聚合后重命名result.columns [‘total_sales‘, ‘sales_p90‘] # 或者对于多级列索引使用result.rename(columns{...})6.3 分组后数据顺序问题groupby默认会对分组键进行排序。有时你可能希望保持数据在原DataFrame中出现的顺序。# 默认排序 sorted_groups df.groupby(‘City‘, sortTrue).sum() # 保持原始顺序按分组键在数据中首次出现的顺序 unsorted_groups df.groupby(‘City‘, sortFalse).sum()sortFalse参数在分组键是类别型category且你有自定义顺序时特别有用。6.4 内存占用过大对非常大的数据集进行多列分组和复杂聚合可能导致内存激增。排查与优化检查分组键基数如果分组键的唯一值非常多例如对用户ID分组那么分组数量会巨大导致内存爆炸。考虑是否可以先进行数据采样或分层汇总。使用as_indexFalse在groupby中设置as_indexFalse会让分组键作为普通列返回而不是作为索引。在某些情况下这可以改变内部表示有时能节省内存但更主要的是为了结果格式的方便。分块处理对于超大数据可以考虑使用pandas的chunksize参数读取文件或使用Dask这类并行计算库。6.5 与时间序列重采样混淆对于时间序列数据按时间频率分组如“按M月度”通常使用resample方法而非groupby。# 正确的时间序列分组重采样 ts_df.resample(‘M‘).mean() # 按月重采样求平均 # 对比按月份名称分组不连续 ts_df.groupby(ts_df.index.month).mean()resample处理的是连续时间窗口而groupby基于离散的标签。在处理规则时间序列时resample是更合适、功能更强大的工具。掌握Pandas分组聚合就像掌握了数据分析的“语法”。它让你能从海量数据中快速提炼出有意义的模式和信息。我个人的体会是初期多花时间理解“拆分-应用-合并”这个核心模型并熟练使用agg和transform后期在遇到复杂业务逻辑时才能灵活地将这些基础操作组合起来优雅地解决问题。当你不再需要为每一个分组统计去写循环时你就真正跨过了数据分析的第一道门槛。