☰
Pandas数据处理:合并、分组聚合与透视表实战指南
2026/10/22 23:15:43 网站建设 项目流程

1. 数据合并:把分散的数据表拼成一张完整蓝图

刚开始用pandas做数据分析时,我经常遇到一个很真实的困境:数据从来不会整整齐齐地躺在一张表里等着你。业务系统的订单表是一张表,用户信息在另一张表,商品类目又单独放一张表。真正要开始分析的时候,第一步永远是“把相关数据拼起来”。这就是DataFrame合并最核心的价值——它解决的是数据组织层面的问题,而不是具体算法层面的问题。

pandas里负责合并的API主要有三类,很多初学者容易混用,但其实它们的定位非常清晰:concat负责“物理拼接”,merge负责“按键匹配”,join是merge在索引场景下的便捷写法。下面把这三类方法的适用场景、语法细节和隐性坑位逐一拆开。

1.1 concat:纵向追加和横向拼接

concat是合并操作里最直观的一个,它的核心逻辑是“沿着某个轴把多个DataFrame直接拼起来”。默认axis=0表示纵向拼,也就是把行增加;axis=1表示横向拼,也就是把列增加。

举个例子,假设你有两个DataFrame,分别记录了一周前三天和后四天的销售明细:

import pandas as pd df_first = pd.DataFrame({ '日期': ['2024-01-01', '2024-01-02', '2024-01-03'], '销售额': [1200, 1500, 980] }) df_second = pd.DataFrame({ '日期': ['2024-01-04', '2024-01-05', '2024-01-06', '2024-01-07'], '销售额': [2100, 1800, 1650, 2200] }) df_week = pd.concat([df_first, df_second], axis=0, ignore_index=True)

这里有两个非常关键的参数:ignore_index=True和keys。如果你不设置ignore_index=True,拼接后的行索引会保留原来的索引值,运气好是0到2和0到3,那么你得到的DataFrame索引会变成0、1、2、0、1、2、3,这种重复索引在处理时很容易搞出奇怪的结果。我几乎每次用concat都会显式写上ignore_index=True,除非我真的有保留原始索引的业务需求。

另一个容易踩坑的地方是列不完全一致的情况。比如前一个DataFrame有“日期”“销售额”两列,后一个多了一列“退货数”,默认concat会生成空值:

df_second_extra = pd.DataFrame({ '日期': ['2024-01-04', '2024-01-05'], '销售额': [2100, 1800], '退货数': [10, 5] }) df_result = pd.concat([df_first, df_second_extra], axis=0, ignore_index=True)

这个结果里,“退货数”这一列在前三行会是NaN。如果你确定只是希望取交集列,可以设置join='inner'。老实说,比如要做环比分析,两边列结构本来就不完全一样,join='inner'反而是更安全的做法。

1.2 merge:按列合并与四种连接方式

在实际业务分析中,merge的使用频率远高于concat,因为大多数关联需求都是“按某一列或者某几列把两张表对齐”,而不是单纯地上下拼接。merge的底层逻辑非常接近SQL的JOIN,对于熟悉数据库的人很容易理解。

常用的参数是on、how和suffixes。on指定键列,how指定连接方式。连接方式主要有四种:

连接方式行为说明适用场景
inner只保留两边都匹配得上的行只看交集,比如只统计有订单又有用户信息的记录
left保留左边表全部行,右边匹配不上填NaN主表在左,副表补充信息,是最常用的分析方式
right保留右边表全部行,左边匹配不上填NaN与left相反,实际中用得相对少
outer两边全部保留,匹配不上各自填NaN需要看全量差异时使用

用一份常见的订单表和用户表来演示:

orders = pd.DataFrame({ '订单号': ['A001', 'A002', 'A003', 'A004'], '用户ID': ['U01', 'U02', 'U01', 'U04'], '金额': [99, 199, 59, 399] }) users = pd.DataFrame({ '用户ID': ['U01', 'U02', 'U03'], '姓名': ['小张', '小李', '小王'], '城市': ['北京', '上海', '广州'] }) result = orders.merge(users, on='用户ID', how='left')

这段代码执行完后,订单数据一条都不会少,而用户表里没有对应关系的“小王”不会出现在结果中,虽然“小王”在用户表里存在,但因为他没有下过单,所以不会出现在left连接的结果里。反过来,如果用inner,就只保留U01和U02对应的订单记录,也就是3条而不是4条。

merge还有一个容易让新手困惑的问题:当两张表都有“金额”这种同名字段时,pandas会自动加后缀_x和_y。比如订单表里有“金额”,支付表里也有“金额”,合并后你会看到“金额_x”和“金额_y”。这在你同时要分析订单金额和支付金额时其实挺好用,但如果你觉得后缀刺眼,可以主动用suffixes=('_订单', '_支付')指定更清晰的后缀。

1.3 join:索引对齐的合并方式

join是merge在索引对齐场景下的快捷方式。很多表在清洗后,行索引本身就是业务主键,比如用户ID、日期等。这时候用on参数反而要多敲几行代码,直接用join按索引合并更省事。

例如:

df_price = pd.DataFrame({'价格': [15, 20, 25]}, index=['苹果', '香蕉', '橙子']) df_stock = pd.DataFrame({'库存': [100, 80, 60]}, index=['苹果', '香蕉', '橙子']) df_combined = df_price.join(df_stock, how='inner')

join本质上是merge(left, right, left_index=True, right_index=True, how=...)的缩写。这里how的逻辑和merge一致。有一个细节需要注意:如果右侧表的索引有重复值,join会像我后面要讲到的merge一样产生笛卡尔积,导致行数膨胀,这时候要特别小心。

2. 分组聚合:从明细数据中提炼业务规律

如果说合并解决的是“数据凑齐了”的问题,那么分组聚合解决的就是“从凑齐的数据里看出门道”的问题。我见过太多新人写数据分析代码时陷入一种循环:不断df[df['城市'] == '北京'],然后分组求均值。这样不仅代码啰嗦,而且写出来的分析思路是碎片的,很难完整地回答业务问题。

groupby的设计思想其实就是“拆分-应用-合并”。先把数据按某个维度拆成若干组,然后对每一组应用某个函数或操作,最后把各组结果合并起来。这个概念不复杂,但真正熟练运用它,需要理解几个关键点:分组键的指定方式、聚合函数的选择、agg与apply的区别,以及transform的逐行映射能力。

2.1 groupby的基础用法与数据拆分逻辑

groupby可以按单个字段分组,也可以按多个字段分级分组。单字段是最常见的:

import pandas as pd import numpy as np sales = pd.DataFrame({ '城市': ['北京', '上海', '北京', '广州', '上海', '广州', '北京', '上海'], '商品': ['A', 'B', 'A', 'B', 'A', 'A', 'B', 'B'], '销售额': [500, 600, 450, 700, 800, 650, 300, 900] }) city_group = sales.groupby('城市') city_total = city_group['销售额'].sum()

执行完这段代码,city_total是:

城市 北京 1250 上海 2300 广州 1350 Name: 销售额, dtype: int64

这是分组的天然结果,本身是个Series。想要保持DataFrame结构,可以用.reset_index(),或者直接用as_index=False参数:

city_total_df = sales.groupby('城市', as_index=False)['销售额'].sum()

再说多字段分组。多字段分组的意义在于观察组合维度下的数据表现,比如想知道每个城市每种商品卖了多少:

city_item = sales.groupby(['城市', '商品'], as_index=False)['销售额'].sum()

这个结果会按城市和商品两列的组合排列。需要注意的是,groupby默认会在分组列上排序,如果你希望保持数据原本出现的顺序,可以加sort=False。数据量大时,sort=False还能节省一点计算时间,虽然提升不一定明显,但思路是对的。

2.2 agg一次性应用多个聚合函数

我在实战中极少只对一个字段求和。很多时候业务方会问:“这个城市总体销售额多少?单均多少?卖出多少单?最大的一单是多少?”这时候如果一个个算再合并到一起,效率很低。agg就是专门解决这个问题的。

stats = sales.groupby('城市')['销售额'].agg(['sum', 'mean', 'count', 'max', 'min'])

执行后,你会得到一张统计表,每一列对应一个聚合函数。这里有个小技巧:你能给结果列重命名,让输出更好读:

stats = sales.groupby('城市')['销售额'].agg( 总销售额='sum', 平均客单='mean', 订单数='count', 最大单='max', 最小单='min' ).reset_index()

需要注意,agg里的函数名必须是能被pandas识别的聚合函数,字符串形式支持'sum'、'mean'、'count'、'max'、'min'、'median'、'std'、'size'等,也可以直接传入NumPy函数如np.sum。但自定义的lambda函数在agg里的效率不如在apply里高,如果只是简单的逻辑,建议直接用apply。

2.3 apply与transform:跨行取值和保留原形状的处理

apply和transform是新手很容易混淆的两个方法,但它们的语义差异非常明显。

apply会对每个分组独立执行你传入的函数,它的灵活性最高。比如你想算“每个城市销售额最高月份的销售额占该城市总销售额的比例”,这种计算如果用agg是做不到的,必须apply:

def top_sales_share(group): top = group['销售额'].max() total = group['销售额'].sum() return top / total shares = sales.groupby('城市').apply(top_sales_share)

这个例子里,函数接收一个分组DataFrame作为输入,返回一个标量。apply会把各组的结果组装成一个Series。

transform的输出形状和输入一致,它不会减少行数,所以你可以把计算结果直接赋给原DataFrame的新列。这个特性在做“原数据加派生字段”的场景下极其实用。

举个例子,我想给每个订单增加一列“该城市平均销售额”:

city_avg = sales.groupby('城市')['销售额'].transform('mean') sales['城市平均销售额'] = city_avg

执行后,每一行都会保留对应的城市平均销售额,而且行数、顺序都和原表一致。这一点是transform和agg最本质的区别。很多新人在需要“按分组计算新字段”时,会先去算聚合结果再去merge,其实直接用transform一步到位,既省代码又不容易出错。

2.4 分组聚合的常见操作禁忌

  • 不要忽略分组后索引的处理。如果后续还要和其他DataFrame合并,最好用as_index=False或者reset_index(),否则你会在索引对齐上花掉不少时间。
  • 不要用apply做简单的聚合。agg在性能上通常优于apply,因为agg是向量化执行,而apply在底层会有更多Python级循环。数据量大时,这个差距会被放大。
  • 不要忘记groupby之后可能产生多级索引。多列分组后reset出来的索引是MultiIndex格式,后续操作建议先reset成普通列,否则很多场景操作起来非常别扭。

3. 数据透视表:用一张表回答多维度分析问题

数据透视表是Excel用户最熟悉的数据分析工具之一,而pandas的pivot_table把同样的能力搬到了Python里。它和Excel透视表的核心逻辑一致,但表达能力更强,而且可以自动化嵌入复杂的分析流程里。

很多人觉得透视表和分组聚合很像,确实,pivot_table本质上是在做“分组聚合 + 行转列”的组合操作。但两者有一个明显区别:groupby返回的长表结构,适合继续做清洗和计算;而pivot_table返回的宽表结构,更适合直接阅读和汇报。业务人员看宽表时能更快地比较不同维度的数值差异。

3.1 pivot_table的核心参数与行列取值

pivot_table的核心参数是index(行维度)、columns(列维度)、values(要聚合的值)和aggfunc(聚合方式)。

来一个标准的例子。还是用上面的销售数据,想知道每个城市在不同商品上的销售额矩阵:

sales = pd.DataFrame({ '城市': ['北京', '上海', '北京', '广州', '上海', '广州', '北京', '上海'], '商品': ['A', 'B', 'A', 'B', 'A', 'A', 'B', 'B'], '销售额': [500, 600, 450, 700, 800, 650, 300, 900] }) pivot_sales = sales.pivot_table( index='城市', columns='商品', values='销售额', aggfunc='sum' )

执行结果:

商品 A B 城市 北京 950 300 上海 800 1500 广州 650 700

这张表的逻辑非常清楚:行是城市,列是商品,交叉点是销售额。一眼就能看出上海B商品的销量扛起了大盘,而北京B商品相对偏弱。这就是透视表在汇报场景中比长表更直观的原因。

如果需要同时看多个指标的汇总,比如既看销售额合计又看订单数,可以用列表传参:

pivot_multi = sales.pivot_table( index='城市', values='销售额', aggfunc=['sum', 'count', 'mean'] )

这样你会得到MultiIndex列名的输出,列名会是('sum', '销售额')、('count', '销售额')这种形式。如果需要把这些结果和原有数据整合,建议加上.reset_index(),再把MultiIndex列名拍平。

3.2 pivot与pivot_table的区别

pandas里还有一个pivot方法,很多新手分不清它和pivot_table的区别。简单说:pivot要求原始数据在指定的行列组合上没有重复,否则会报错“Index contains duplicate entries”;而pivot_table会通过聚合函数自动处理重复数据。

举个例子,假如原始数据里“北京+A商品”出现了两次:

sales_dup = pd.DataFrame({ '城市': ['北京', '北京'], '商品': ['A', 'A'], '销售额': [500, 450] }) # 会直接报错 pivot_dup = sales_dup.pivot(index='城市', columns='商品', values='销售额') # 会正常输出并求和 pivot_table_dup = sales_dup.pivot_table( index='城市', columns='商品', values='销售额', aggfunc='sum' )

所以,除非你非常确定数据不会重复,否则一律用pivot_table更安全。pivot的适用场景其实很窄,更多时候它只是pivot_table在无重复数据下的简化版。

3.3 用crosstab快速生成频次统计

crosstab是pivot_table在频次统计场景下的特化版。它的输入是两个(或多个)序列,输出的是一个交叉频次表,非常适合做分类变量之间的关系探索。

例子:

df_survey = pd.DataFrame({ '性别': ['男', '女', '男', '女', '女', '男', '男', '女'], '是否复购': ['是', '是', '否', '是', '否', '否', '是', '是'] }) cross = pd.crosstab(df_survey['性别'], df_survey['是否复购'])

输出:

是否复购 否 是 性别 女 1 2 男 2 2

这个表能直接告诉我们男女在复购行为上的基本分布,而且实现代码极为简短。crosstab也支持normalize='index'来输出行比例的分布,这在分析占比时特别方便:

cross_rate = pd.crosstab(df_survey['性别'], df_survey['是否复购'], normalize='index')

这样一来,每一行都代表该性别内部复购与不复购的比例,能够更公平地比较男女之间因为样本量不同而产生的差异。

3.4 数据透视表结果清洗与再操作

透视表生成之后,往往还需要做一步收尾工作。最常用的操作是把透视结果转回普通DataFrame:

pivot_sales_reset = pivot_sales.reset_index()

如果你还需要把宽表转成长表,用melt或者stack。我个人觉得,凡是要继续做数据清洗和建模的,透视结果都应该转回长表;凡是要输出给业务看的,才保留宽表形式。

4. 常见问题与排查技巧实录

在使用DataFrame合并、分组聚合和数据透视表的过程中,有几个问题几乎是每个数据分析师都会遇到的。下面把它们整理成一份速查表,并分享一些实战中的处理思路。

4.1 合并后行数膨胀

这是merge最容易踩的坑之一。当两个DataFrame的键列都有重复值时,merge会产生笛卡尔积,即两边匹配行数相乘的结果。

举例来说,左表有两条“U01”的订单记录,右表里“U01”出现了两次(比如用户有两条联系方式),合并后就会产生4条记录。这在业务上可能是有意义的扩展,也可能是一个数据质量问题。排查时我通常的做法是先在合并前检查键列的重复情况:

orders['用户ID'].duplicated().sum() users['用户ID'].duplicated().sum()

如果左右两边都有重复,就要先想清楚“重复的含义是什么”,再决定是先对右表去重,还是用drop_duplicates保留需要的那条记录。

4.2 合并后数据的空值处理

用left或outer连接时,总会有一些行在右表中找不到匹配,结果产生NaN。这些NaN不代表没有数据,而是代表“没有匹配上”。分析之前一定要区分这两种情况。

我的习惯是,先把合并结果的空值情况统计出来,再决定填充方式。如果空值占比过高,首先要怀疑键列的数据类型不一致。一个非常典型的场景是:订单表里的“用户ID”是字符串类型,用户表里的“用户ID”是数值类型,导致merge的时候一个也匹配不上。这时候用astype(str)或者astype(int)统一键列类型,问题立刻解决。

orders['用户ID'] = orders['用户ID'].astype(str) users['用户ID'] = users['用户ID'].astype(str) merged = orders.merge(users, on='用户ID', how='left')

这个坑出现的频率相当高,尤其是当数据来自不同系统时,ID的存储格式不同几乎是常态。

4.3 分组聚合后索引混乱

groupby后如果没有及时重置索引,后续操作很容易出现“明明看着是列,却变成了索引”的情况。最常见的排查方法就是打印df.index和df.columns,确认数据形态是否符合预期。

我建议在完成分组聚合后,直接养成一个习惯:df.groupby(...).agg(...).reset_index()。保持数据以干净列形式呈现,后续做各种操作都会顺畅很多。

4.4 透视表的空值与填充策略

pivot_table默认会对没有交叉数据的单元格填充NaN。很多业务报表中,这些NaN其实更适合显示为0。你可以在生成透视表之后直接fillna(0):

pivot_sales_filled = pivot_sales.fillna(0)

但要注意,如果是比率类的聚合结果,比如[aggfunc='mean']计算平均单价,空值填充为0可能会扭曲业务含义。这时候要结合场景判断,不要机械地统一填充。

4.5 性能优化实用笔记

数据量上了千万级之后,合并和分组的性能问题会变得非常明显。几个可以立刻见效的优化习惯:

  • 在merge之前,先对右表的键列做去重。如果确定右表键值是唯一的,加上drop_duplicates(subset=['键列'])可以大大减少计算量。
  • 用categorical类型代替字符串类型。对低基数的分类字段(比如城市、商品类别)转成category类型,groupby和pivot_table的运算速度会肉眼可见地提升。
  • 避免在循环里反复调用merge。如果多个表需要按顺序逐层合并,尽量先把所有需要的数据提前准备好一遍合并,减少中间步骤。
  • 大数据量情况下,直接用polars或者duckdb处理,会比pandas快一个量级以上。如果你的数据规模已经到了“跑一次要等半分钟”的程度,那么换个工具引擎是更理性的选择,没必要死磕pandas。

5. 小结心得:把合并、分组与透视组合成分析链条

在我实际做了大量数据分析工作之后,最大的体会是:这三个功能很少单独使用,它们通常串成一条完整的数据处理链路。合并负责把数据积聚起来,分组聚合负责提炼维度上的统计指标,透视表负责把这些指标以适合阅读的形式展示出来。链路一旦跑通,你就能真正从原始数据中快速生成一张可以回答问题、驱动决策的报表,而不是停留在“一个个算指标再手动拼接”的低效阶段。

我建议每个学习数据分析的读者都亲手做一次完整的练习:用一份销售明细数据,先通过merge把商品信息补全,再用groupby加agg计算出各城市和各商品的核心指标,最后用pivot_table输出一份可以汇报给业务的宽表。这个过程会把你对pandas的理解从“会用某个函数”提升到“能设计数据加工流程”的层次,而这种流程设计能力,才是数据分析工作真正值钱的部分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询