尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Pandas Groupby与透视表实战:从数据聚合到报表生成

Pandas Groupby与透视表实战:从数据聚合到报表生成 做数据分析这几年我越来越觉得pandas最值回票价的部分不是那些花哨的取数操作而是它把“汇总”这件事做成了体系。项目标题说“Groupby和透视方法太全面了”这句话我深有同感。日常处理订单、用户行为、财务流水不管原始数据多乱最终落到汇报和决策上基本都是两个动作按维度分组算指标再按行列结构做透视。如果你正在学pandas或者已经用了一段时间但遇到多条件汇总就写出一堆for循环那这篇文章应该能帮你省不少事。下面我从环境准备开始逐步拆解groupby的全部核心用法、透视表/交叉表/长宽表转换的实战细节再补上数据格式读写和类型转换这些容易踩坑的地方。内容会偏实操代码都是我平时跑过的你可以直接拿去做模板。1. 先把环境打理好安装、版本与数据准备1.1 Python版本与pandas版本怎么配先说一个最容易被忽略的问题pandas版本和Python版本不匹配会导致装不上或者运行报错。特别是Python 3.10之后很多早期pandas版本并没有适配碰到No matching distribution或者编译报错非常正常。如果你用的是Python 3.10建议直接装pandas 2.0以上版本。pandas 1.4.0虽然宣称支持3.10但很多扩展功能比如某些字符串方法、Parquet引擎联动在1.4.x上还是会遇到兼容问题。Python 3.11、3.12同理装最新的稳定版pandas是最省心的选择。# 建议先升级pip再安装pandas python -m pip install --upgrade pip pip install pandas2.0装完之后在Python环境里验证一下import pandas as pd print(pd.__version__)如果你看到类似2.1.4这样的输出说明环境正常。注意如果你同时用numpy、scikit-learn这些科学计算库最好统一在虚拟环境里装避免系统全局环境的依赖冲突。1.2 PyCharm里安装pandas的两种方式很多新手在PyCharm里安装pandas会失败原因通常是当前解释器不是虚拟环境或者pip源访问太慢。第一种方式打开File - Settings - Project - Python Interpreter点加号搜索pandas再点Install Package。这种方式适合不想敲命令的人如果下载慢可以在Manage Repositories里把镜像源换成国内源。第二种方式直接在PyCharm底部Terminal里执行pip install pandas。我更推荐这种方式因为输出更清晰能看到到底装到了哪个环境。如果你发现Terminal里执行pip后PyCharm运行代码还是提示找不到pandas大概率是项目解释器和Terminal用的Python不是同一个。此时在解释器设置里点一下右上角的Show All再点路径旁边的文件夹图标确认解释器路径与which python输出一致。1.3 准备一份适合练手的模拟数据后面所有例子我都会围绕一份电商订单明细来展开。字段包括订单编号、用户ID、省份、商品类目、订单金额、订单数量、下单时间、支付状态。这种数据结构在工作中非常常见用它来演示groupby和透视表得到的结果很容易迁移到自己的业务里。import pandas as pd import numpy as np np.random.seed(42) df pd.DataFrame({ order_id: range(1, 1001), user_id: np.random.choice([U001, U002, U003, U004, U005], 1000), province: np.random.choice([广东, 浙江, 江苏, 四川, 湖北], 1000), category: np.random.choice([手机数码, 服饰鞋包, 食品生鲜, 家居家装], 1000), amount: np.round(np.random.uniform(50, 5000, 1000), 2), quantity: np.random.randint(1, 5, 1000), order_date: pd.date_range(2023-01-01, periods1000, freqh), status: np.random.choice([已完成, 已取消, 待付款], 1000, p[0.8, 0.1, 0.1]) })这份数据有1000行字段类型覆盖了数值、字符串、日期非常适合用来跑groupby和透视表的全流程演示。2. Groupby汇总从基础分组到多级聚合2.1 三句话理解Groupby的核心逻辑很多人学groupby时只记住了“分组”但pandas里groupby真实的工作方式是三个步骤拆分、应用、合并。拆分就是按照某一列或多列把DataFrame切成若干个小块应用就是对每个小块执行操作比如求和、求均值、取第一个合并就是把结果拼接成一个新的DataFrame。第一步裸用groupby加聚合函数这也是最基础的操作# 按省份统计订单总金额 df.groupby(province)[amount].sum()如果不指定[amount]会对所有数值列都做聚合有时候会产生一堆没用的列。所以我个人习惯是groupby后一定要明确选择列再跟聚合函数。这样代码可读性高执行效率也更高。第二步多列分组。比如按省份和支付状态两个维度看订单金额df.groupby([province, status])[amount].sum()这里得到的结果是MultiIndex也就是两层索引。很多人在这一步会困惑为什么结果看起来像一堆括号套括号。解决办法是加reset_index()把索引变成普通列。第三步控制是否排序。groupby默认会按分组键排序。如果你希望保持原数据中分组键出现的顺序用sortFalsedf.groupby(province, sortFalse)[amount].sum()这个细节在生成报表时很有用。比如你想让“广东”永远出现在第一行而不是按首字母排在前面就必须显式设置sortFalse然后再按自己定义的顺序做reindex。2.2 一次Groupby搞定多个聚合指标实际业务里我们通常不会只求一个sum而是同时要总额、均值、最大单笔、订单数。基础写法是分别计算再用pd.concat合并但这太啰嗦。agg方法一次就能搞定。result df.groupby(province)[amount].agg([sum, mean, max, min, count]) result.columns [总金额, 平均金额, 最大单笔, 最小单笔, 订单数]这里有个非常容易踩的坑count统计的是非空值的数量。如果你的订单金额列有缺失值count的结果会少于实际行数。所以统计订单量时我习惯用size它不管字段是否为NaN都会计数。summary df.groupby(province).agg( 订单数(order_id, size), 总金额(amount, sum), 平均金额(amount, mean), 最大单笔(amount, max) )这种写法用了pandas 0.25引入的NamedAgg语法列名直接在agg里定义省去后面改列名的步骤。如果你一次性聚合多个字段比如金额求和、数量求和、订单数统计建议配合pd.NamedAgg或者上面这种元组列表的方式比agg([sum, mean])更清晰。2.3 不同字段用不同聚合方式这是agg最强大的地方。你可以简单理解为给每个字段配一个专属的聚合策略。比如订单金额用sum订单数量用mean订单id用nunique去重计数result df.groupby(province).agg( 总金额(amount, sum), 平均每单数量(quantity, mean), 用户数(user_id, nunique), 订单数(order_id, count) )nunique特别值得多说一句。很多新人用count去统计用户数结果把同一个用户的多笔订单都算进去了导致用户数虚高。如果业务上要求“去重用户数”必须用nunique。还有一种场景是统计支付状态种类比如每个省份出现了几种状态也可以用nunique。2.4 按时间维度分组Grouper的妙用数据里有下单时间我们经常需要按天、按月、按小时汇总。直接在order_date列上groupby默认会把每个精确的时间戳都当成一组结果几乎和原始数据一样多行。正确做法是配合pd.Grouper指定频率# 按月汇总订单金额 df.groupby(pd.Grouper(keyorder_date, freqM))[amount].sum()如果要同时按省份和月份分组需要把Grouper放进列表里df.groupby([province, pd.Grouper(keyorder_date, freqM)])[amount].sum()这里freqM表示按自然月D表示天h表示小时W表示周。注意pandas 2.2之后M仍然可用但官方推荐用ME表示月末。这两种在使用上差别不大但新版为了语义清晰已经把M标记为deprecated。写新代码时建议直接用ME。2.5 典型场景按组取最新一条等价于SQL里的orderbygroupby去重很多人在PHP/Laravel里遇到过这样一个需求orderBy(created_at)之后groupBy(user_id)然后取每个用户最新的一条记录。我当年写SQL时也试过各种别扭的写法其实pandas里做这件事非常简单。假设我们想取每个用户最新一笔订单的完整记录latest_orders df.sort_values(order_date).groupby(user_id).tail(1)注意这种方法返回的是每个用户最后一行的所有字段可以直接用来做后续分析。还有一种思路是用drop_duplicates基于user_id去重保留最后一个latest_orders df.sort_values(order_date).drop_duplicates(subsetuser_id, keeplast)两种写法得到的结果一样。区别在于第一种更好理解第二种在数据量大时内存表现通常更优因为drop_duplicates不需要在groupby内部构造分组对象。如果你要基于“两列”去重比如同一个用户同一个类目只保留一条记录就把subset[user_id, category]传进去。3. 高级聚合操作transform、filter和apply3.1 transform让聚合结果回到原始行数agg返回的结果行数等于分组数量但很多业务场景要求我们在原始DataFrame上新增一列这一列的值是每个用户自己的订单金额占总金额的百分比。这就需要一个神奇的操作聚合结果广播回每一行。transform就是干这个的。它返回的结果和原始DataFrame行数完全一致。df[用户总金额] df.groupby(user_id)[amount].transform(sum) df[用户金额占比] df[amount] / df[用户总金额]这个例子非常实用。比如你想找出“某用户在某个省份下的消费占比”一次transform就能算出来。transform能用的函数包括sum、mean、max、min等内建聚合函数以及lambda自定义函数。# 把每个用户的消费金额做标准化观察离群用户 df[金额Z分数] df.groupby(user_id)[amount].transform(lambda x: (x - x.mean()) / x.std())这里要提醒一下transform里的lambda如果涉及复杂的Python循环性能会比较差。能用内建函数就用内建函数。比如上面这个Z分数如果只是想看相对差异用transform(lambda x: x - x.mean())也够了。3.2 filter只保留符合条件的分组有时候我们不关心分组里的具体行而是想过滤掉某些分组。比如只保留订单数大于等于50的省份。用filter可以在分组层面做筛选结果返回的是原始行的一个子集。filtered df.groupby(province).filter(lambda x: len(x) 150)这个操作的含义是只要某个省份的总订单数达到150笔就把这个省份的所有订单行保留下来。filter里的lambda接收的是每个分组的小DataFrame返回值必须是布尔值。你还可以在lambda里加更复杂的条件比如“分组金额大于10万且订单数大于100”。filter和普通布尔索引最大的区别在于普通布尔索引作用在行上filter作用在整个分组上。这两个语义完全不同用错的话结果会差很多。3.3 apply万物皆可盘但别滥用apply是最灵活的也是性能坑最多的地方。它可以对每个分组执行任意函数返回聚合结果、原始DataFrame过滤结果甚至一个新的DataFrame。也就是说apply能覆盖agg、transform、filter的功能但代价是慢。def top2_with_rate(group): top2 group.nlargest(2, amount) total group[amount].sum() top2[占比] top2[amount] / total return top2 result df.groupby(province).apply(top2_with_rate)这段代码会返回每个省份金额最高的两笔订单并附上它们在该省总金额中的占比。这种逻辑用纯agg很难写用apply就很自然。但我给你的真心建议是能用agg和transform解决的不要用apply。因为apply会对每个分组执行Python级别的循环数据量一大速度会以肉眼可见的速度下降。如果非要按行做复杂运算优先思考能不能把逻辑拆成几列分别计算比如用groupby().cumsum()或者groupby().rank()。这些向量化方法通常比apply快一个数量级。4. 透视表pivot_table全面拆解4.1 pivot_table核心参数一览如果说groupby是“纵向按组汇总”那么pivot_table就是“横向按行列交叉汇总”。它解决的问题是既要按行维度分组又要按列维度交叉展示。pivot pd.pivot_table( df, valuesamount, indexprovince, columnsstatus, aggfuncsum, fill_value0 )执行之后你会得到一个以省份为行、以支付状态为列的表格。每个交叉点的值就是该省份在该状态下的订单金额。fill_value0会把没有数据的位置填充为0避免看到一多串NaN。pivot_table还可以多级行列pivot pd.pivot_table( df, valuesamount, index[province, category], columnsstatus, aggfunc[sum, count], marginsTrue, margins_name合计 )这里marginsTrue会额外增加一行“合计”和一列“合计”相当于Excel透视表里的总计功能。对于做汇报的人来说这个参数非常方便省去自己再算一遍汇总。4.2 用aggfunc实现不同的汇总口径透视表并不是只能求和。aggfunc支持几乎所有聚合函数包括mean,count,sum,max,min,median,nunique。# 各省份各状态下去重用户数 pivot_users pd.pivot_table( df, valuesuser_id, indexprovince, columnsstatus, aggfuncnunique, fill_value0 )如果values有多个字段aggfunc也可以传一个字典为不同字段指定不同聚合方式。比如金额用sum数量用mean用户数用nunique。4.3 把groupby结果变成透视表结构很多时候你已经用groupby算出了结果但希望展示成透视表的布局。最常用的两个方法是unstack和stack它们负责在行索引和列索引之间做“解堆叠”和“堆叠”。# 先按省份状态分组求和 g df.groupby([province, status])[amount].sum() # 把二级索引解堆叠成列 wide g.unstack()unstack()默认把最后一层索引变成列结果是和pivot_table很接近的宽表。反过来stack()可以把宽表变回长表。理解了这两个方法就等于掌握了pandas中Index和Column互换的本质。4.4 pivot与pivot_table的区别pandas里还有一个更简单的pivot方法但它的限制是不能有重复的行列组合。如果同一个省份、同一个状态下有多行数据pivot会直接报错“Index contains duplicate entries”。而pivot_table会通过aggfunc自动聚合不会报错。所以日常使用我基本只用pivot_table。如果数据在透视后是一对一的用pivot也行但你要额外确认数据没有重复风险更小的是直接用pivot_table指定一个aggfuncfirst。5. 交叉表与长宽表转换crosstab、melt和stack5.1 crosstab快速统计频次和占比如果你只想看两个离散变量的交叉频次pd.crosstab比pivot_table更直接。它不需要指定values因为默认就是计数。cross pd.crosstab(df[province], df[status])这样你会得到各省份和支付状态的交叉频次表。如果希望输出占比用normalize参数# 按行归一化每一行总和为1即各省份内各状态占比 cross_row_pct pd.crosstab(df[province], df[status], normalizeindex) # 按总金额占比 cross_amount_pct pd.crosstab( df[province], df[status], valuesdf[amount], aggfuncsum, normalizecolumns )normalizeindex是按行归一normalizecolumns是按列归一normalizeTrue则按全表归一。这在做画像分析和漏斗分析时很常用比如看不同省份哪个支付状态的用户占比更高。5.2 melt宽表转长表透视和交叉都是把长表变宽而真实工作中我们经常还要把宽表变回长表。最典型的场景是Excel里给了你一张“省份×月份”的销售矩阵每列是一个月份的销售额你希望把它转换成“省份、月份、销售额”三列的长表才能继续接groupby。pd.melt就是干这个的。wide_sales pd.DataFrame({ province: [广东, 浙江, 江苏], 2023-01: [100, 200, 150], 2023-02: [110, 210, 160], 2023-03: [120, 220, 170] }) long_sales pd.melt( wide_sales, id_vars[province], value_vars[2023-01, 2023-02, 2023-03], var_name月份, value_name销售额 )id_vars表示要保留的标识列value_vars表示要“熔化”成值的列。如果value_vars不写默认对除id_vars外的所有列做melt。通常建议明确写出避免Excel表格里混入其他无关列导致结果异常。5.3 交叉表、透视表、melt、stack怎么选遇到不同需求选择哪种方法经常让新人纠结。我的选择逻辑是这样的需要统计每个分组下的某个指标用groupby agg。需要同时按两个维度展示交叉汇总用pivot_table。需要看两个离散变量的交叉频次用crosstab。需要把宽表变成适合后续汇总的长表用melt。需要在已有的groupby结果上把行索引变成列索引用unstack。需要在长表和宽表之间来回切换并继续聚合用stack/unstack配合groupby。5.4 数据大也不要慌Parquet和Feather格式实战处理数据量比较大时CSV文件读起来慢、占空间也大。这时候可以用Parquet或Feather列式存储格式。这两个格式在pandas中支持很好底层依赖pyarrow或fastparquet。安装一下依赖pip install pyarrow写Parquetdf.to_parquet(orders.parquet, enginepyarrow, indexFalse)读Parquetdf pd.read_parquet(orders.parquet)Feather格式更轻量读写速度更快适合单机临时保存中间结果。Parquet则更适合长期存储和列裁剪因为它的压缩率通常更高而且支持只读取特定列。需要说明的是这些格式都是“单机内存”级别的大数据方案。如果数据已经大到单机装不下Spark、Dask这类分布式框架才是更合适的方向。但日常处理几百万行数据pandasnumpyparquet这套组合已经能覆盖绝大多数业务分析需求。顺便一提用numpy加速pandas的常见套路是提前把数值列转为numpy数组或者使用pd.array扩展类型。比如对一组数据做向量化计算时df[amount].to_numpy()得到一维ndarray再做数学运算比直接用Series在某些场景下快。在聚合之前做这些类型转换也能顺带解决后面要说的“类型坑”。6. 类型转换与重复值处理汇总前必须扫清的地雷6.1 数据类型转换astype、to_numeric、to_datetime在大量使用groupby和透视表之前你最好先把每一列的数据类型搞清楚。否则经典问题会接踵而至金额是字符串sum直接变成字符串拼接日期是字符串按“天”分组完全没法用。最常用的三个转换函数# 货币金额带逗号比如 1,234.56先去掉逗号再转float df[amount_clean] pd.to_numeric(df[amount].str.replace(,, ), errorscoerce) # 日期字符串转成datetime类型 df[order_date] pd.to_datetime(df[order_date], format%Y-%m-%d %H:%M:%S) # 整数列转category节省内存并提升groupby速度 df[province] df[province].astype(category)errorscoerce的作用是遇到无法转换的值直接变成NaN而不是让整个程序报错。这在清洗脏数据时几乎是必用的。astype(category)可以显著减少占用内存尤其当某个字段的取值数量远小于行数时比如省份、支付状态改成category后在groupby上计算速度也会有提升。6.2 指定两列值相同只保留第一条的完整方案这是热词里明确提到的一个需求。假设你有一张埋点日志表每行代表一次事件字段包括设备ID、页面URL、访问时间。你希望同一个设备同一个页面只保留最早的一次访问记录。# 方式一drop_duplicates dedup_df df.sort_values(order_date).drop_duplicates(subset[user_id, category], keepfirst) # 方式二groupby first dedup_df df.sort_values(order_date).groupby([user_id, category]).first().reset_index()两种方式的区别在于返回结果的结构。方式一返回的是原始DataFrame的行的子集保留了所有原始列。方式二返回的是分组后的第一条索引是分组键需要reset_index()恢复。如果数据量很大方式一通常更快因为它不构造分组内部对象。这个思路和第2.5节“取每个用户最新一条”本质是同一个问题。只要调整keep参数就能灵活保留最早或最新。6.3 重复值对聚合结果的影响重复数据不处理就直接groupby后果可能很严重。比如同一笔订单在明细表里出现了两次你按省份求和时会把这笔订单金额算两遍汇报数字一下子虚高。判断是否有重复行df.duplicated().sum() df.duplicated(subset[order_id]).sum()duplicated()会返回一个布尔Seriessum()就是重复行数。如果发现重复你可以选择直接删除也可以标记后单独排查。强烈建议在跑任何汇总前先确认主键是否唯一。如果有人问“为什么我groupby求和后数字对不上”十有八九是重复行的问题。6.4 我在实际项目中踩过的3个隐蔽坑NaN分组的坑。groupby默认会把NaN单独作为一组。如果某列存在缺失值分组汇总结果里会出现一行索引为NaN的数据。这不是bug但报表里出现NaN那行经常让人摸不着头脑。如果你希望忽略这些分组需要在groupby前用dropna()或者fillna()先处理缺失值。category类型的坑。如果省位列被转成category而某个省份在抽样数据中没有出现groupby后这个省份也会显示出来且聚合结果为0或者NaN。这在某些场景下是好事但在另一些场景下会误导人。如果你只希望对“实际有数据的组”汇总可以先df[province].cat.remove_unused_categories()或者在groupby前对category列做一次astype(str)。时间聚合结果的坑。使用pd.Grouper(keyorder_date, freqM)时如果order_date列不是datetime类型会直接报TypeError。这类报错信息其实还好。更隐蔽的是如果数据里混入了NaT缺失时间按月分组时会产生一个NaN分组的聚合结果。所以做时间序列汇总前养成先df[order_date] pd.to_datetime(df[order_date], errorscoerce)再dropna(subset[order_date])的习惯能省很多事。7. 把这些方法串起来一张完整的汇总报表流程我最后用一个综合案例把全文内容串一遍。假设你是业务分析师领导让你出一份“各省份各品类销售周报”要求包含总金额、订单数、去重用户数、环比变化还要能直接贴到Excel里做透视。第一步数据清洗和类型转换df[order_date] pd.to_datetime(df[order_date]) df[province] df[province].astype(category) df df.dropna(subset[order_date]).drop_duplicates(subset[order_id])第二步生成周维度字段df[week] df[order_date].dt.to_period(W).astype(str)第三步用groupby完成多维度多指标聚合report df.groupby([province, category, week]).agg( 订单数(order_id, count), 总金额(amount, sum), 用户数(user_id, nunique), 平均单笔(amount, mean) ).reset_index()第四步用pivot_table做成为周为列、省份品类为行的宽表pivot_amount report.pivot_table( index[province, category], columnsweek, values总金额, aggfuncsum, fill_value0 )第五步写回Excel方便业务同事自己二次透视with pd.ExcelWriter(周报.xlsx) as writer: report.to_excel(writer, sheet_name明细汇总, indexFalse) pivot_amount.to_excel(writer, sheet_name周度透视)整个过程没有使用任何循环核心全靠groupby和pivot_table。这就是我一直说的pandas的汇总方法论一旦掌握大部分日常报表需求都能在十几行内解决。最后再分享一个我自己一直在用的小技巧当你groupby的逻辑越来越复杂时可以把整段分组聚合封装成一个函数再用pipe调用。比如df.pipe(generate_report, freqW)这样报表逻辑可以复用也方便测试。毕竟pandas能力再全面也不如我们自己的代码组织得足够清晰可靠。
返回列表