pandas高效数据处理:合并、聚合与透视全解析

发布时间:2026/10/22 16:53:26
pandas高效数据处理:合并、聚合与透视全解析
做数据分析这几年我面试过不少人也帮团队带过新人。有一个现象特别明显很多同学单独问DataFrame合并、分组聚合、数据透视表这些 API都能背得头头是道可真扔给他们一堆乱糟糟的 Excel 导出的多表数据让做一张销售汇总报表往往耗时一上午还总在“数据怎么变多了”“索引怎么是两层”“透视出来全是 NaN”这些问题上打转。这篇博文不打算按官方文档把函数签名列一遍而是从实际数据处理链路出发把merge/concat/groupby/agg/transform/pivot_table/crosstab这几个高频操作揉在一套模拟数据里讲清楚什么时候该用哪个、为什么这么用、背后是哪种对齐逻辑以及那些谷歌半天才查到的坑。适合刚入门 pandas 的读者也适合干了一年半载想补一补“为什么”的人。要是你正在找工作这三大块几乎是 pandas 面试的必问分区。要是你在做业务分析这套东西是日常处理明细数据变成可读报表的骨架。我们直接开始。1. 合并操作的门道merge、concat、join 按用途分开用很多新手把所有表拼在一起的活儿都交给一个函数处理其实 pandas 里三个合并手段各有明确侧重点。merge是按“列键”对齐的 SQL 风格连接concat是按“轴方向”做堆叠或拼接join本质上就是merge的索引便捷版。按错的场景用你才会遇到后面各种诡异问题。1.1 merge 的匹配逻辑与参数陷阱我用一套模拟零售明细来讲。假设手上有三个表订单表orders_df、产品表products_df、客户表customers_df。import pandas as pd orders_df pd.DataFrame({ order_id: range(1, 11), customer_id: [101, 102, 103, 104, 101, 102, 105, 106, 107, 108], product_id: [P001, P001, P002, P003, P002, P003, P001, P004, P004, P005], quantity: [1, 2, 1, 5, 3, 2, 1, 4, 2, 3], amount: [100, 200, 150, 400, 350, 220, 80, 180, 120, 260], payment_method: [card, cash, card, cash, card, cash, card, card, cash, card] }) products_df pd.DataFrame({ product_id: [P001, P002, P003, P004, P005], product_name: [台灯, 耳麦, 键盘, 鼠标, 摄像头], category: [家居, 数码, 外设, 外设, 数码], unit_price: [100, 150, 80, 45, 90] }) customers_df pd.DataFrame({ customer_id: [101, 102, 103, 104, 105, 106, 107, 109], city: [上海, 北京, 深圳, 广州, 杭州, 成都, 武汉, 南京], region: [华东, 华北, 华南, 华南, 华东, 西南, 华中, 华东] })最常见的操作是把订单明细和产品信息合并拿到每笔订单对应的产品类别order_prod orders_df.merge(products_df, onproduct_id)这行代码背后的逻辑是orders_df里product_id有重复products_df里product_id唯一属于多对一合并。结果的行数等于左表行数因为右表没有造成数据膨胀。合并后建议立刻检查行数是否和订单表一致assert len(order_prod) len(orders_df) print(order_prod.shape)有人会问一个merge到底怎么判断是多对一、一对多还是多对多答案是它不判断它只会把键匹配的行做笛卡尔积组合。如果两边键都有重复结果就会爆行。这是表格合并时最危险的坑后面我会专门讲。实操中我强烈建议在 merge 里加上validate参数。比如刚才这个场景期望是右表键唯一就写validatemany_to_oneorder_prod orders_df.merge(products_df, onproduct_id, validatemany_to_one)如果数据里产品表真有重复的product_idpandas 会直接抛异常帮你提前发现问题而不是等报表给到业务方后才发现数字全都翻倍了。再看一个细节。orders_df里有amount某些场景下products_df也可能有叫amount的列比如“退款金额”。两个表合并后重名列会被自动加上_x、_y后缀。你需要记得用suffixes参数去改成能读懂的名字order_prod orders_df.merge( products_df, onproduct_id, suffixes(_order, _product) )我还常用indicatorTrue来找出“哪些订单没匹配到产品”。这个参数会在结果里生成一列_merge取值是both/left_only/right_only。业务分析中经常出现部分订单引用了已下架产品不查出来就会在后续统计里悄悄漏掉merged_df orders_df.merge( products_df, onproduct_id, howleft, indicatorTrue ) bad_rows merged_df[merged_df[_merge] left_only] print(bad_rows)how参数我一般按业务语义选要全订单就howleft只要对上号的就默认 inner要看两边数据全貌用 outer。需要提醒一点howouter会出现 NaN 行越到后面的分组聚合、透视越要记得这些 NaN 代表什么。1.2 concat 的堆叠逻辑以及与 merge 的本质区别pd.concat经常被误用作“万能合并”。它跟merge最大的不同在于对齐方式merge 对齐的是“列里的键”concat 对齐的是“轴标签”。如果要把几个结构一致列名完全一样的月度订单表纵向堆起来这才是concat的正确场景monthly_orders pd.concat([order_jan, order_feb, order_mar], axis0, ignore_indexTrue)axis0表示往下追加行ignore_indexTrue表示不用原来各表自己的行号重新生成 0 到 n-1 的连续索引。要是不加这个参数合并出来的表索引是重复的后续一groupby或者reset_index就会出各种莫名其妙的问题。如果追加的多个表来自不同门店、不同批次你想在堆叠后还能追溯到数据来源可以用keys参数造一个二级索引monthly_orders pd.concat( [order_jan, order_feb, order_mar], axis0, keys[2025-01, 2025-02, 2025-03] )这样每一行都能通过外层索引看出属于哪个月之后reset_index()还会自动把月份变成一列非常直观。不过我不建议无脑用keys只有当月度来源信息不在表内时才需要表里本身有月份字段就直接练ignore_index。concat还有一种横向拼接用法axis1它按索引对齐。这个逻辑特别容易让人误解以为它会“智能匹配数据”实际它只是行索引对上就并排对不上的就补 NaN。比如你有一堆按日计算的指标表索引是同一天的日期横向拼接没问题。但如果是两张索引错位的表拼接出来的 NaN 会很多这时就应该考虑 merge 而不是 concat。一句话总结merge 处理“主键关联”concat 处理“叠表或者按索引并排”。用错了轻则效率低重则数据串行。1.3 join合并到索引上的便捷操作join我把它理解为merge的语法糖专门处理“用索引当键”的场景。很多聚合结果的行索引是分组键比如月份、城市这时候要和另一个同样以月份或城市为索引的表做合并写merge要先reset_index()而join直接搞定。典型例子月度销售汇总要和月度销售目标表比对。目标表的键是月份且放在索引里monthly_sales { 2025-01: 120000, 2025-02: 135000, 2025-03: 98000, } goal_df pd.DataFrame(list(monthly_sales.items()), columns[month, goal]).set_index(month) # 销售明细先按月份聚合 summary_by_month order_prod.assign( monthpd.to_datetime(order_prod[order_id]).dt.month # 演示用实际按日期字段 ).groupby(month)[amount].sum() # 直接用索引 join result summary_by_month.to_frame(amount).join(goal_df) result[goal_rate] result[amount] / result[goal]这里summary_by_month的索引就是月份和goal_df的索引能对上。如果不习惯索引操作可以reset_index()然后用merge结果一样。关键是要记住join适合左表用索引、右表也用索引或者右表用索引、左表用某个普通列通过on指定的场景。2. 分组聚合的核心运行逻辑split-apply-combine 与 ag 语法细节分组聚合是整个 pandas 里性价比最高的技能点同时也是初学者最容易被索引绕晕的地方。理解了机制写出来的代码又短又稳。2.1 groupby 之后的索引是怎么多出来的groupby背后的机制叫 split-apply-combine先把数据按分组键拆成若干小组然后对每个小组应用聚合函数最后把结果拼回一张表。麻烦出在最后一步pandas 默认会把参与分组的那几列变成结果的索引。看这个例子按“区域”汇总销售额order_prod orders_df.merge(products_df, onproduct_id).merge(customers_df, oncustomer_id, howleft) region_sum order_prod.groupby(region)[amount].sum()输出是一个 Series索引是region。看起来没什么问题。但如果按两个键分组sum_by_region_category order_prod.groupby([region, category])[amount].sum()索引立刻变成两层 MultiIndex。你要是把这当普通 DataFrame 拿去merge大概率会报错“cannot reindex from a duplicate axis”因为多层索引在连接时很难直接对齐。所以我的习惯是分组聚合要保留成 DataFrame 且分组键变成普通列时直接写as_indexFalsesum_by_region_category order_prod.groupby( [region, category], as_indexFalse )[amount].sum()这样结果天然有region、category两列后续无论是合并、透视还是写出报表都省心。老代码里常见的套路是先groupby(...).sum().reset_index()其实等价于as_indexFalse两者选一个就行我个人推荐后者少写一行。2.2 agg 的各种写法从多函数到命名聚合聚合函数不只有 sum。agg是分组后应用“一个或多个聚合函数”的统一入口用法很灵活。最基本的是字符串写法basic order_prod.groupby(region, as_indexFalse)[amount].agg(sum)想同时看订单笔数、金额合计和平均客单可以传列表region_stats order_prod.groupby(region, as_indexFalse)[amount].agg([sum, mean, count])这里的列名会变成sum、mean、count和原始列名混在一起读起来有点乱。新版 pandas 提供了命名聚合NamedAgg强烈推荐region_stats order_prod.groupby(region, as_indexFalse).agg( 总销售额(amount, sum), 平均单额(amount, mean), 订单笔数(order_id, count), )写法和可读性都提升了一个档次而且聚合结果直接用中文名或者业务名后续做报表少很多改列名的操作。还有按不同列用不通用的写法比如想看各个区域数量合计和金额均值可以这样region_mix order_prod.groupby(region, as_indexFalse).agg( quantity_sum(quantity, sum), amount_mean(amount, mean), )我见过不少人在agg里传字典比如.agg({amount: sum, quantity: mean})这在旧版本 pandas 上列顺序不稳定而且可读性差现在基本被我换成了命名聚合。如果你的团队里还有人传字典建议统一改成 NamedAgg 写法。2.3 transform 和 apply什么时候用它们而不是 aggagg有一个硬性要求每个函数对每个分组必须返回标量这样结果才会是“分区汇总表”。但业务里经常有另一个需求你想在每条明细后面跟着一列“所属分组的平均值”也就是既要保留原始粒度又要带上聚合信息。这时用transform最合适。它返回的是和原 DataFrame 等长的结果常见用法是把聚合值广播回每行。举个例子计算每个产品在其类别中的销售额占比order_prod[category_amount] order_prod.groupby(category)[amount].transform(sum) order_prod[category_ratio] order_prod[amount] / order_prod[category_amount]这个逻辑在报表里很常用。如果用agg先算出类别汇总你还得merge回去多写几行。而transform天然在内部帮做了标签对齐一行搞定。apply则更自由每个分组会被传进一个函数函数里可以返回标量、Series、甚至 DataFrame。比如我想看每个区域销售额最高的前 3 笔订单top3_each_region ( order_prod.groupby(region, group_keysFalse) .apply(lambda g: g.nlargest(3, amount)) .reset_index(dropTrue) )注意这里的两个小设置group_keysFalse是为了不让分组键重复出现在结果索引里reset_index(dropTrue)是清掉nlargest保留下来的原始索引。如果以前没加这两个参数你大概率见过 apply 返回结果莫名多一列region或者索引层级很奇怪。apply灵活性大但性能一般在处理几十万行以内的小规模数据无所谓几百万行数据就尽量用内置 agg 和 transform 优化。这属于性能思维面试时也是一个加分点。3. 数据透视表从 pivot 到 pivot_table 再到 crosstab透视表在 Excel 里用得顺手到 pandas 里很多人却不适应主要是因为pivot和pivot_table太像了但行为完全不同。再加上crosstab混进来选择困难又上一层。3.1 pivot 与 pivot_table 的本质差别pivot要求每个“行索引 列索引”交叉位置最多只有一个值否则会直接报错。pivot_table则多了一个aggfunc参数遇到重复值会帮你聚合。换句话说只要数据里有同一天同一个产品有两笔单子pivot 就崩了而 pivot_table 默认算平均。实操中我不再用pivot直接全部用pivot_table。你想把多行明细变成一个二维矩阵时最自然的写法是sales_pivot pd.pivot_table( order_prod, valuesamount, indexregion, columnspayment_method, aggfuncsum, fill_value0, marginsTrue, margins_name合计 )这个透视表一眼就能看到每个区域不同支付方式的总销售额右下角还有总计。marginsTrue是特别实用的参数它会自动在行和列末位加上小计/总计省得你用sum(axis1)再去算一遍。3.2 多级透视和列索引的处理技巧当你要看“区域 × 支付方式 × 月份”这样三个维度时可以把维度分别放进index和columnssales_pivot_multi pd.pivot_table( order_prod, valuesamount, index[region, category], columnspayment_method, aggfuncsum, fill_value0 )结果的行变成了两层索引列是card、cash两层没有多级列的话就是单层。取数据时要注意写法想取“所有区域、摄像头的 card 支付额”不能直接写df[card]因为列索引还是普通字符串而行索引是 MultiIndex需要用df.loc[(slice(None), 摄像头), card]。如果行索引层级太反人类直接reset_index()把层级拍扁反而更好操作flat sales_pivot_multi.reset_index()这个操作会让透视表接近“宽表”形态后续如果要 merge 或者其他操作都方便很多。3.3 crosstab专门做频数统计的表crosstab可以理解成“专门做计数透视”的便捷函数。两个离散变量想看组合分布一行代码就行cross_tab pd.crosstab(order_prod[category], order_prod[payment_method])输出是一个行名类别、列名支付方式、单元格计数的交叉表。加normalizeindex能看每个类别内部支付方式占比cross_tab_pct pd.crosstab( order_prod[category], order_prod[payment_method], normalizeindex )如果不想计数而是想对某个数值列做汇总也能通过values和aggfunc指定cross_tab_amount pd.crosstab( order_prod[category], order_prod[payment_method], valuesorder_prod[amount], aggfuncsum )其实crosstab内部就是对两个 Series 调了pivot_table只是面对的数据形态更简洁。所以如果你已经会用pivot_tablecrosstab只是省了两行传参而已。3.4 透视表与 groupby unstack 的关系很多文档把透视表当作独立功能讲但你要是从原理去看pivot_table就是“分组聚合后把某个分组键展开成列”的过程。换句话说sales_pivot_equivalent order_prod.groupby( [region, payment_method], as_indexFalse )[amount].sum().pivot(indexregion, columnspayment_method, valuesamount)或者更直接sales_pivot_equivalent2 order_prod.groupby( [region, payment_method] )[amount].sum().unstack()unstack负责把行索引里最内层的一组提到列上。理解了这一点透视表的很多参数行为比如什么值该放 index、什么值该放 values、aggfunc 为什么存在就不需要死记了它只是分组聚合的再组织。4. 把三件套串成一条完整的分析链路光散讲函数不够我拿一个完整的模拟场景把合并、分组、透视全部过一遍。假设你是某零售企业的数据分析师手上有订单明细、产品表、客户表和月度销售目标表需要给业务出一张“区域 × 月份 × 品类”的销售看板。第一步数据合并与质量检查。订单表先合并产品表拿到品类再合并客户表拿到区域。合并不只是调用函数关键是检查行数变化full_df ( orders_df .merge(products_df, onproduct_id, validatemany_to_one) .merge(customers_df, oncustomer_id, howleft, indicatorTrue) ) print(原始订单数:, len(orders_df)) print(合并后行数:, len(full_df)) print(客户匹配失败数:, (full_df[_merge] left_only).sum())这里我用indicatorTrue查看哪些订单找不到客户。如果匹配失败的记录有业务含义就应该保留并在后面分析里单独标注如果没有匹配上可能是脏数据需要找 IT 确认。第二步月度销售汇总。给订单增加月份字段然后按月份聚合full_df[month] full_df[order_id].mod(12) 1 # 演示用模拟月份 monthly_summary full_df.groupby(month, as_indexFalse).agg( 订单笔数(order_id, count), 销售额(amount, sum) ) print(monthly_summary)第三步生成区域月度销售矩阵。业务方喜欢透视表在一个表格里横向看到每个月的数据region_month_pivot pd.pivot_table( full_df, valuesamount, indexregion, columnsmonth, aggfuncsum, fill_value0, marginsTrue, margins_name总计 ) print(region_month_pivot)第四步每个区域找出销售额最高的产品类别。这里需要先做区域 × 品类的聚合再对每个区域取第一名region_category full_df.groupby( [region, category], as_indexFalse )[amount].sum() top_category ( region_category .sort_values(amount, ascendingFalse) .groupby(region, group_keysFalse) .head(1) .reset_index(dropTrue) ) print(top_category)这个做法比apply更直白先全局排好序再对每组取 head(1)避免了apply返回索引混乱的问题效率也更高。第五步把月度销售完成率和目标表对比goal_df pd.DataFrame({ month: [1, 2, 3], goal: [80000, 90000, 100000] }) summary_with_goal monthly_summary.merge(goal_df, onmonth, howleft) summary_with_goal[达成率] summary_with_goal[销售额] / summary_with_goal[goal] print(summary_with_goal)这套流程跑完基本覆盖了日常工作里八成以上的“写报表”需求。你会发现每一步都不难难的是把每一步的数据形态变化想清楚谁和谁对齐、行数变没变、索引是谁。5. 实战中躲不开的坑位清单最后把这些年带新人和自己做项目时常遇到的坑集中盘点一遍每一个都是真实报错或错误结果换来的经验。5.1 合并膨胀与键重复这是我最想强调的一条。merge 遇到两边键都有重复会做笛卡尔积结果行数变成两份重复数的乘积。比如订单表里有 3 个重复的产品键产品表里又有 2 个重复键合并后那组就变成 6 行。出现这种问题先给每个表的键做duplicated检查duplicated_in_orders orders_df[product_id].duplicated(keepFalse).sum() duplicated_in_products products_df[product_id].duplicated(keepFalse).sum() print(duplicated_in_orders, duplicated_in_products)更省事的办法是合并时传validateone_to_many或many_to_one。pandas 检测到键不唯一会直接抛异常避免错误结果悄悄流到下游。5.2 groupby 结果忘记处理索引前面提过多次groupby默认把分组键变成索引。如果后面要 merge、join、或者写 CSV 再读回来索引可能成为隐患。我的习惯是聚合后顺手加as_indexFalse让数据始终保持在“宽表 普通列”的形态能省掉后面至少一半的 debug 时间。5.3 透视表的默认均值行为pivot_table只传值不传aggfunc时默认是mean。有时候你只是想看一个单元格里多条记录的加总忘了指定aggfuncsum得出的数字会看起来“偏小”却很难一眼发现问题。所以写透视表第一步先确认values对应数据的语义再定aggfunc不要偷懒省略。5.4 fill_value 与 margins 的联动问题fill_value0只影响透视表中空单元格的展示值不会改变 margins 里的总计计算逻辑。如果原始数据某个交叉点没有记录填了 0那横向或纵向的小计也会把 0 加进去没问题但如果交叉点是 NaN 而不是 0margins 计算时可能忽略 NaN导致行小计不等于各列之和。碰到这种情况先确认原始数据里是否缺组合再用dropna和fillna在透视前处理好比透视后修数据靠谱。5.5 transform 不注意数据类型transform(sum)对数值列没问题但如果混入了字符串列某些 pandas 版本会尝试把字符串拼起来或者直接报错。我的习惯是transform前先只选数值列例如category_sum full_df.groupby(category)[amount].transform(sum)5.6 concat 之后索引重复用concat纵向拼接多张表忘了ignore_indexTrue时结果里会有多个相同的行号。之后做groupby一般没事但要是有一步使用索引定位就容易踩进“索引不可唯一使用”的报错里。保险的做法是纵向 concat 一律加ignore_indexTrue除非你真的需要保留批次索引。一些个人习惯我自己处理这类数据时很少把函数混在一起一把梭。每个步骤保持“输入一张表 → 输出一张表”的节奏中间用assert校验行数用indicator检查匹配情况再进入下一个环节。这样即使后面业务口径变更改起来也快代码可读性也高换个人来接手也不会对着变量名猜半天。如果让我给一个学习路径上的建议先把merge和groupby练到闭眼写这两个是地基然后把pivot_table的 index / columns / values / aggfunc 搞清楚最后再碰apply和transform这些需要理解分组的进阶工具。顺序反了的话你会被各种索引问题追杀很容易劝退。最后分享一个小技巧当你觉得一张表数据看起来不对时不要盯着结果怀疑人生先回到上一步的输入检查行数和重复键。绝大多数合并、聚合、透视的问题在数据进入函数之前就已经注定了。