资讯详情

资讯详情

Pandas groupby分组聚合、变换与性能优化实战

1. 数据分组这件事为什么总让人手忙脚乱数据分组这四个字听起来像是表格软件里点两下筛选的事可真到了写代码的阶段十个里有八个会在groupby上卡一下。我带的几个新人里几乎每个人第一次做“按城市统计销售额”这种需求时都会先写一段 for 循环加字典累加跑完发现结果对不上再花半小时 debug。问题不在于他们不会写循环而在于没有建立起“分组—计算—合并”这个思维模型。groupby这个 Python 里最常用的数据处理动作本质上就是把一件事拆成三步做先按某个规则把数据切成若干堆再对每一堆独立算一遍最后把结果拼回来。理解了这个模型剩下的全是配方问题。这篇内容面向的是已经有 Python 基础、会用一点 pandas但每次遇到“分组统计”“分组内排名”“分组后填充”这类需求就要去翻文档的人。我会把groupby从设计思路到实操细节拆开讲包括分组键的五种写法、agg/transform/apply/filter四个动作到底该选哪个、多级分组怎么做透视、几百兆数据下怎么把它跑快以及我自己踩过的那些坑。所有代码都可以直接复制到本地跑数据用模拟的订单流水不涉及任何真实业务。先说结论groupby真正难的从来不是语法而是你到底想要什么形状的结果。想要一行一组的汇总表用agg想要和原表等长的新列用transform想要分组内的前 N 条明细用apply或nlargest想要按组的整体特征筛掉一批组用filter。选错动作代码能跑通但结果一定是错的而且错得很隐蔽。下面我们一层层拆。1.1 手工分组的三种典型写法以及它们各自的毛病在正式讲groupby之前先看看没有它的时候大家是怎么干的这样对比下来你才知道它省掉的到底是什么。第一种写法是用字典加循环遍历每一行把键相同的数据塞进同一个列表。这种写法逻辑最直白新手最容易想到但它的问题是必须把全部数据先读进内存再逐行处理十万行数据还行一百万行就开始明显卡顿而且代码里全是if key not in d这种样板写完自己都不想看第二遍。第二种写法是先把数据按键排序再用itertools.groupby顺序扫一遍。这个思路其实很正统itertools.groupby就是干这个的性能也好但它有个致命的隐含前提数据必须已经按分组键排好序。如果没排序同一个键的数据分散在文件的不同位置groupby会把它当成好几个不同的组结果就是分组数莫名其妙变多。我在早期处理日志文件时被这个坑过整整一个下午最后发现是上游导出的数据顺序变了。第三种写法是先set出所有唯一键再对每个键做一次全表过滤。这种写法代码短但复杂度是 O(键数 × 行数)键一多就直接爆炸属于能跑但绝对不该上生产的类型。三种写法各有各的适用场景但共同的问题是它们都只解决了“分组”没解决“分组之后干什么”。而实际需求里分组只是开头后面跟着的求和、求均值、求每组最大值所在的那一行、按组标准化才是真正的工作量。groupby的价值就在于把这些后续动作标准化了让你不用每次都重新造轮子。1.2 groupby 的抽象模型拆分、计算、合并groupby的设计遵循一个非常经典的范式叫拆分—计算—合并。拆分阶段pandas 会根据你给的键在内部建立一张“键 → 行索引列表”的映射表这个过程只遍历一次数据复杂度是 O(n)。计算阶段你把一个函数交给它它对每一组的数据分别执行彼此完全独立天然适合并行。合并阶段pandas 负责把每组的返回值拼成一个新的结构拼接方式取决于你用的是哪个动作。这个模型最妙的地方在于计算阶段和合并阶段是解耦的。同一份分组结果你可以拿去做求和返回标量合并成一行一组也可以拿去做标准化返回等长序列合并成和原表一样长还可以拿去做筛选返回布尔值合并成原表的子集。这就是为什么groupby后面能接那么多方法它们其实是同一个模型的不同出口。提示groupby对象本身是惰性的你写下df.groupby(city)的时候什么计算都不会发生它只是记录了“按 city 分组”这件事。真正的计算发生在你调用sum()、agg()、apply()的那一刻。所以调试时如果觉得某一步特别慢先确认是不是在这里触发了计算。理解了这一层你会发现很多“奇怪”的行为都变得合理了。比如为什么groupby之后head()返回的是每组前几行而不是整个表的前几行为什么groupby().sum()的分组键会跑到索引上去都是这个模型直接推出来的结果不是随意的设计。1.3 三个工具怎么选pandas、itertools、手写字典实际工作里能用来做分组的不止 pandas 一家选错了工具会多花很多冤枉时间。我整理了一张对照表按我自己的使用经验标了适用场景工具返回结构适用场景主要限制pandas.groupbyDataFrame / Series表格数据分析、需要接聚合统计单机内存超大数据吃力itertools.groupby迭代器流式处理、日志逐行扫描、超大文件必须先按相同键排序手写字典累加自定义逻辑极简、依赖少的小脚本代码冗长扩展性差SQL / 数据库结果集数据本来就在库里、量特别大需要额外环境我的判断标准很简单数据已经是 DataFrame就用 pandas 的groupby不要犹豫数据是逐行读进来的文本流、而且内存装不下就用排序加itertools.groupby数据在数据库里而且上亿行直接在库里聚合把结果拉回来。最忌讳的是明明手上有 DataFrame却非要df.values.tolist()转成列表再手写循环白白丢掉向量化带来的几十倍性能。还有一点值得说现在有些场景我会考虑换引擎。当单表到千万行、分组键又特别多的时候pandas 的groupby会明显吃内存这时候 DuckDB 直接在 DataFrame 上跑 SQL 聚合或者用 Polars 的group_by速度会好不少。但这属于优化阶段的事先把手上的groupby用明白比换引擎重要得多。2. 拆开 groupby 的四个核心动作分组、聚合、变换、过滤很多人学groupby的方式是背 APIsum会了、mean会了、agg会了然后就卡住。正确的学习路径是先搞清楚动作的分类再去记具体方法名。我把所有groupby之后能接的操作归成四类聚合把一组压成一个值、变换一组变一组、长度不变、过滤按组整体特征决定留不留、以及带任意逻辑的应用什么都能干但最慢。四类的区别用一句话就能说清结果的行数变了没有变了是怎么变的。聚合会让行数变少一组一行变换行数完全不变输出的长度和输入一样过滤也是变少但保留的是整组。而apply是个万能口袋你让它返回什么形状它就能拼成什么形状代价是慢、而且返回结构不稳定容易出意外。实际写代码的时候我给自己定的规矩是能用agg就不用apply能用transform就不用apply只有在“每组返回不定长结果”的时候才动apply。按这个规矩写出来的代码性能稳定可读性也好。2.1 分组键的五种写法以及多键分组后的索引问题分组键是groupby的第一个参数它的灵活程度超出很多人想象。最常用的是传列名字符串df.groupby(city)简单直接。第二种是传一个和原表等长的 Series 或数组比如你按另一张表映射过来的区域划分来分组。第三种是传函数df.groupby(lambda i: i % 7)这种一般用在按索引规律分组实战里不多见但确实有用。第四种是传字典或 Series 做映射比如手上有一张城市到省份的对照表df.groupby(province_map)pandas 会按索引把城市映射成省份再分组。这个用法在“维度需要重新归类”的时候特别好使不用先去merge一张新表。第五种是传列表df.groupby([province, city])多键分组这个是最常用的进阶写法。多键分组有个必须知道的行为结果会变成 MultiIndex多级索引。如果你直接reset_index()或者一开始就加as_indexFalse多级索引会展开成普通列如果不处理后面取值会很难受得用xs或者元组索引去访问。我个人的习惯是一律写as_indexFalse除非我明确要拿多级索引去做unstack转透视表import pandas as pd import numpy as np rng np.random.default_rng(42) n 20000 df pd.DataFrame({ order_id: np.arange(1, n 1), province: rng.choice([华东, 华南, 华北, 西南], n), city: rng.choice([fC{i} for i in range(12)], n), channel: rng.choice([线上, 门店, 代理], n), amount: rng.gamma(2.0, 120.0, n).round(2), }) # 扁平结构方便直接导出 res df.groupby([province, city], as_indexFalse)[amount].sum() print(res.head())顺带说一个细节分组键里如果有NaNpandas 默认会把这一整行丢掉而且不报警告。你的总金额和原始表对不上很多时候就是这里丢的。要保留空值组显式加dropnaFalse这是 pandas 1.1 之后才有的参数老版本只能先fillna。2.2 agg 聚合一組一行以及命名聚合这个真香写法聚合是最常用的动作sum、mean、count、nunique、max、min、std这些都是内置的直接点出来就行。多个指标一起算用列表传进去结果是多级列名。但这种写法的问题是列名不好看amount下面挂着sum、mean、count导出去给业务方看还得手动改名。真正好用的是命名聚合pandas 0.25 之后支持用关键字参数的形式写新列名(原列名, 函数)一次搞定计算和改名结果还是扁平的列名直接就能导出summary df.groupby(province, as_indexFalse).agg( 订单数(order_id, nunique), 总金额(amount, sum), 客单价(amount, mean), 最大单笔(amount, max), )这个写法有个坑要注意命名聚合里不能对同一个源列的两个不同函数使用同一个输出列名也不能和普通的位置参数混用比如agg(sum, 新列(a,mean))会直接报错。要么全用命名形式要么全用列表形式。另外两个高频参数值得单独提。observedTrue在分组键是 category 类型时非常关键不加的话 pandas 会把所有类别组合都算一遍哪怕某些组合在数据里根本不存在结果里就冒出一堆 0 行还特别慢。sortFalse能省掉分组后的排序开销如果你不关心输出顺序加上它在大数据量下能省个百分之十几的时间。2.3 transform 变换结果和原表等长这是最容易被忽略的利器我见过太多人用groupby().agg()算完每组均值然后再merge回原表去算差异其实一行transform就够了。transform的特点是返回的 Series 和原表长度一样、索引一致可以直接赋值成新列这是做组内对比、组内占比、组内标准化的标准姿势。# 每组均值直接广播回每一行 df[province_avg] df.groupby(province)[amount].transform(mean) # 组内占比 df[share_in_province] df[amount] / df.groupby(province)[amount].transform(sum) # 组内排名 df[rank_in_province] df.groupby(province)[amount].rank(ascendingFalse, methoddense) # 组内标准化 g df.groupby(province)[amount] df[zscore] (df[amount] - g.transform(mean)) / g.transform(std)transform支持的函数范围比agg窄一些内置的统计函数都能用自定义函数也行但自定义函数必须返回标量或者和组等长的序列返回值长度不对会直接抛错。这一点比apply严格但正是这种严格让它的结果可预测。我在实际项目里的经验是凡是“算出来要拼回原表”的需求先想transform想不出来再考虑apply。注意transform里的自定义函数拿到的是一列Series不是整个子 DataFrame。如果你想在函数里同时用两列得用df.groupby(...)[[a,b]].transform(...)或者在函数外面先把需要的列算出来。这个限制经常让新人困惑其实是为了保证返回长度可控。2.4 apply 与 filter什么时候必须用它们apply是万能钥匙也是性能杀手。它把每一组的子 DataFrame 完整交给你你想返回什么形状都行返回一个标量结果是一行一组返回一个 Series结果是一行一组多列返回一个 DataFrame结果会拼成一个更大的表。最典型的必须用apply的场景是“每组取金额最大的前三条明细”因为结果是不定长的agg和transform都表达不了。top3 df.groupby(province, group_keysFalse).apply( lambda g: g.nlargest(3, amount), include_groupsFalse, )这里有两个参数必须说清楚因为它们经常导致报错或者警告。group_keysFalse控制分组键要不要加进结果的索引不加的话结果就是原表的行结构加的话会多一层索引取数的时候很别扭。include_groups是 pandas 2.2 引入的参数用来控制传进函数的子 DataFrame 里要不要包含分组键列默认行为在版本间变过早期版本会给你一个FutureWarning提示。如果你的 pandas 版本低于 2.2直接写lambda g: g.nlargest(3, amount)就行同时可以g g.drop(columns[province])手动剔掉避免分组键莫名其妙出现在结果里。filter的用法很直白给一个返回布尔值的函数返回 True 的组整体保留返回 False 的组整体丢弃。它和query的区别在于filter的判断依据是组的整体特征比如“订单数大于 500 的城市”“退货率低于 5% 的渠道”。这类需求如果先聚合再筛再 join 回去要写三行用filter一行就完了active df.groupby(city).filter(lambda g: len(g) 800)filter的代价是它需要把每组都完整过一遍数据量大的时候不便宜。如果筛选条件能表达成聚合结果上的条件我更倾向先agg拿到符合条件的键集合再用isin过滤这样通常更快也更容易读懂。3. 完整实操从原始流水到多维报表的一条龙流程光讲语法没意思这一章我把一个完整的需求走一遍手上有一份两万行的订单流水需要产出三个东西——按省份的汇总、按省份加渠道的交叉表、每个省份金额前五的明细最后导成一个多 sheet 的文件。这个流程基本覆盖了日常八成的分组需求你可以把数据换成自己的代码结构直接复用。先说明一下数据的形态这是我自己造的一份模拟数据字段包括订单号、省份、城市、渠道、下单时间和金额金额用 gamma 分布生成这样有长尾更接近真实业务里的订单金额分布。时间字段我特意做成了带时分秒的完整时间戳因为后面要做周维度的分组很多人这一步会把时间存成字符串导致后面Grouper直接报错这个坑后面会专门讲。3.1 数据准备与清洗分组之前必须先想清楚的三件事分组统计出错八成不是groupby写错了而是分组键本身脏。我在动手分组之前一定会检查三件事分组键有没有空值、有没有前后空格或者大小写不一致、类型对不对。这三件事任何一件出问题结果都会莫名其妙地多出几组或者少几组而且不会报错。df[province] df[province].str.strip() df[channel] df[channel].str.strip().str.lower() # 检查空值 print(df[[province, channel]].isna().sum()) # 检查类型 print(df.dtypes) # 分组键的唯一值数量数量对不上说明有脏数据 print(df[province].nunique(), df[province].unique())字符串分组键最常见的两个问题一个是“华东”和“华东 ”多了个空格被当成两组另一个是大小写混用导致“线上”和“线上”看着一样实际不同。这类问题在 Excel 里肉眼看不出来代码里必须显式清洗。类型问题更隐蔽如果省份列里混进了数字或者None分组结果的组数就会多出几个奇怪的键。清洗完之后我习惯先看一眼每个键的行数分布这一步能提前发现很多问题print(df[province].value_counts(dropnaFalse)) print(df[channel].value_counts(dropnaFalse))如果某个键的行数只有个位数而其他键都是几千那基本可以确定是脏数据或者测试数据混进来了。这一步花不了十秒但能省掉后面半小时的排查。3.2 基础分组统计与多级分组的展开第一步产出按省份的汇总表用命名聚合一步到位。这里我特意加了nunique去统计订单号因为同一个订单可能有多条明细行如果直接count会把明细行数当成订单数这是业务口径上最常犯的错误。统计“单量”用nunique统计“行数”用count这两个千万别混我在评审别人的代码时第一眼就看这个。prov_summary ( df.groupby(province, as_indexFalse) .agg( 订单数(order_id, nunique), 明细行数(order_id, count), 总金额(amount, sum), 客单价(amount, mean), 金额中位数(amount, median), ) .sort_values(总金额, ascendingFalse) )用中位数而不是只用均值是我从实际数据里学到的习惯。金额这种长尾分布均值很容易被几笔大单拉高中位数更能反映“典型订单”的水平。两个指标放一起看如果均值远高于中位数说明这个省份有大客户集中业务上和“散户为主”的省份处理策略完全不同。第二步做省份加渠道的交叉表。这里用多级分组加unstack把渠道这个维度转成列得到一张真正的二维表cross ( df.groupby([province, channel])[amount] .sum() .unstack(fill_value0) )unstack之后如果某些省份没有某个渠道的数据会出现NaN用fill_value0直接填 0 更符合报表习惯。这里要提醒一点unstack之后如果列的顺序乱了可以用reindex按你想要的顺序重排不要指望它自动按字母序或者按金额序排好。如果不想用unstack还有一个更直接的写法是pivot_table它内部其实就是groupby加unstack语法更贴近做报表的思维两者选一个用熟就行cross2 df.pivot_table( indexprovince, columnschannel, valuesamount, aggfuncsum, fill_value0, marginsTrue, margins_name合计, )marginsTrue会自动加一行一列的合计做报表的时候特别省事不用再手动拼一行。3.3 分组内 TopN 与组内占比的几个固定套路第三步是每个省份金额前五的明细。这个需求用nlargest加groupby最简洁注意group_keysFalse不然结果会带上一层省份索引导出的时候很难看top5 ( df.sort_values(amount, ascendingFalse) .groupby(province, group_keysFalse) .head(5) .sort_values([province, amount], ascending[True, False]) )这里先说排序再head的写法比apply(nlargest)快很多因为head是向量化实现的apply是逐组调用 Python 函数。我实测过一万多行的数据前者几乎是瞬间后者要几百毫秒数据量再大一个量级差距会更明显。能用向量化方法解决的就别用apply这条经验在groupby上体现得淋漓尽致。组内占比也是高频需求写法就是transform(sum)之后做除法前面已经演示过。这里补充一个细节如果你要算的是“组内排名第一的那条占总量的比例”不要用transform(max)因为max是值不是位置遇到并列会出错正确做法是先rank再筛df[rank_in_prov] df.groupby(province)[amount].rank(methodfirst, ascendingFalse) first_share ( df[df[rank_in_prov] 1] .assign(sharelambda d: d[amount] / d.groupby(province)[amount].transform(sum)) [[province, order_id, amount, share]] )rank的method参数要留意默认是average遇到并列会给出 1.5 这样的平均排名如果你想保证每组只有一条被选中用methodfirst按出现顺序打破并列这个细节在做“取每组第一条”的需求时非常关键。3.4 时间维度的分组Grouper、resample 与分箱按时间分组是另一个高频场景也是最容易出问题的地方。先说过最常见的报错TypeError: Only valid with DatetimeIndex。这个错误基本都是因为时间列是字符串Grouper认不出来。解决方法是先转类型而且一定要加errorscoerce把转不了的脏值变成NaT否则一条脏数据就会让整个转换失败df[order_time] pd.to_datetime(df[order_time], errorscoerce) print(df[order_time].isna().sum()) # 转失败的行数必须看一眼转好之后按周聚合用pd.Grouper指定列名和频率不要用resample因为resample要求 DatetimeIndex而我们的表是普通索引weekly ( df.groupby([pd.Grouper(keyorder_time, freqW), province]) .agg(订单数(order_id, nunique), 金额(amount, sum)) .reset_index() )freqW是自然周M是自然月Q是季度。这里有个版本差异要注意新版 pandas 对M这种简写会给出弃用提示要求写成ME如果你的代码在旧环境和新环境都要跑最好先确认一下版本或者在测试里固定住行为。除了按自然周期分还有一种更灵活的用法是自定义分箱比如把金额切成若干区间再统计各区间的订单数用pd.cut配合groupby就行bins [0, 100, 300, 800, 2000, np.inf] labels [0-100, 100-300, 300-800, 800-2000, 2000] df[amount_band] pd.cut(df[amount], binsbins, labelslabels, rightFalse) band_stat df.groupby(amount_band, observedFalse)[order_id].nunique()pd.cut的rightFalse表示左闭右开这个一定要明确写出来不然边界值落在哪个区间全靠猜不同人对“100 到 300”的理解可能不一致导致口径对不上。observedFalse在这里是故意的因为我希望没有数据的区间也出现在结果里显示 0这样图表上不会缺档。3.5 结果导出多 sheet 一次写完最后把三张表写进一个文件用ExcelWriter一次搞定。这里有个小技巧把索引去掉、列宽设置一下业务方打开就能直接看with pd.ExcelWriter(report.xlsx, engineopenpyxl) as writer: prov_summary.to_excel(writer, sheet_name省份汇总, indexFalse) cross.to_excel(writer, sheet_name省份渠道交叉) top5.to_excel(writer, sheet_name各组Top5, indexFalse)cross那张表保留了索引因为省份就是它的行标签去掉索引反而看不出哪行是哪个省。每张表的index参数要根据表的实际结构决定这个细节看着小但导出的表好不好用差别很大。另外数据量大到几十万行的时候Excel 写入会明显变慢超过一百万行就直接写不进去了这时候改用 CSV 或者直接写数据库更靠谱。4. 性能优化与踩坑实录那些文档里不会写的细节groupby的语法半小时就能学完真正拉开差距的是两件事知道什么写法会慢以及知道报错大概是什么原因。这一章我把这些年积累下来的性能经验和问题清单整理出来都是实际调试出来的不是从文档里抄的。先给出一个总的性能排序从快到慢内置聚合函数sum/mean/count等快于命名聚合快于transform内置函数transform内置函数快于apply返回标量apply返回标量快于apply返回 DataFrame。这个顺序不是绝对的但大方向上不会错。每往右移一格性能大概掉一个数量级所以能用左边的就别用右边的。4.1 让 groupby 跑快的六个具体动作第一只选需要的列。df.groupby(k).agg(...)会把整张表都传进去如果表有五十列而你只需要三列内存和时间的浪费是实打实的。改成df.groupby(k)[[a,b,c]].agg(...)或者更省事的df[[k,a,b,c]].groupby(k)效果立竿见影。第二把字符串分组键转成 category。这是投入产出比最高的一招。字符串比较是逐字符比的category 在内部变成了整数编码分组时的哈希和比较都快得多内存占用也能降一大截。数据量大、分组键重复度高的时候提速非常明显df[province] df[province].astype(category) df[channel] df[channel].astype(category) # 转成 category 之后务必加 observedTrue res df.groupby([province, channel], observedTrue)[amount].sum()这里必须强调observedTrue。category 类型默认会保留所有理论上的类别组合observedFalse时 pandas 会把笛卡尔积全枚举一遍如果你的类别是从全量历史数据里定义的组合数可能上万那性能不但没提升反而暴跌。这个坑我踩过一次一个本该两秒的查询跑了四十多秒最后就是差这一个参数。第三能不用 apply 就不用 apply。前面说过apply是逐组调用 Python 层函数每次调用都有开销。如果一个需求能用agg、transform、rank、cumsum、nlargest组合出来一定要组合别图省事写apply。第四sortFalse关掉排序。groupby默认会把结果按键排序如果你的分组键是几十万种组合这个排序本身就占了相当一部分时间。不关心顺序的时候加上它。第五分块处理。数据量大到接近内存上限时与其硬扛不如按某个维度切块逐块分组、把结果追加到一个列表里最后concat。这样内存峰值可控代码也不复杂。第六换引擎。当单表超过千万行、又需要频繁做多键分组时pandas 的瓶颈会很明显。这时候用 DuckDB 直接对 DataFrame 执行 SQL 聚合或者用 Polars 的惰性求值通常能快几倍到十几倍。不过这是最后一步前面的优化做完再看需不需要。4.2 常见报错与异常结果的排查速查表下面这张表是我这些年在groupby上遇到过的典型问题按出现频率排序遇到问题可以先扫一遍现象大概率原因处理方式分组数比预期多键有空格、大小写不一致、混入脏值先striplowervalue_counts检查总数比原表少键里有NaN默认被丢弃加dropnaFalse保留空值组结果多出一堆 0 行category 键枚举了未出现的组合加observedTrue报错输出长度不匹配自定义函数返回长度不等于组长度检查函数返回值或改用applytransform结果没对齐索引有重复或乱序先reset_index(dropTrue)再操作时间分组报类型错误时间列是字符串pd.to_datetime(..., errorscoerce)结果索引取不到列多键分组产生了 MultiIndex加as_indexFalse或reset_index()apply出 FutureWarning新版 pandas 对分组键列入参行为变化显式传include_groupsFalse并按需剔列内存瞬间爆掉整表传入 高基数键 未选列只选需要的列转 category分块处理结果顺序每次不一样未指定sort分组键是无序类型显式sort_values或sortTrue除了表里的这些还有一个特别隐蔽的问题结果里出现了重复的行。这种情况通常是分组键本身有重复值导致的正常行为但也有可能是你在apply里返回了带原始索引的 DataFramepandas 在拼接时把索引也带上了看起来就像多了几行。判断方法是看结果的index是不是有重复有的话reset_index(dropTrue)一下就能确认。4.3 三条我认为最值钱的实操心得第一条先想清楚输出形状再动手写代码。我现在的习惯是写groupby之前先在纸上画一下结果应该有几行、每行代表什么、有哪些列。这一步想清楚了动作选哪个基本就定了代码一遍就过。反过来边写边试的结果往往是改来改去最后写出一堆嵌套的apply自己都看不懂。第二条永远拿一个小样本验证口径。分组统计最怕的不是报错是静默算错。我的做法是每次写完随机抽一个组手工把它的原始数据筛出来用最笨的方式算一遍和groupby的结果对一下。这个习惯帮我抓到过好几次“用count当单量”和“把NaN组漏掉”的问题。样本量不用大一百行足够。第三条别怕把中间结果落下来看。groupby的链式写法很好看但调试的时候很痛苦。我的做法是调试阶段把每一步都赋给一个变量print一下形状和前几行确认没问题再合并成一条链。看着啰嗦但比在一行长表达式里找问题快得多。等逻辑稳定了再收拾成链式写法那时候你已经知道每一步是什么了。5. 几个可以直接抄的分组套路最后这一章我整理了几个高频场景的现成写法都是我在不同项目里反复用到的。这些不是 API 教程而是“需求来了直接改改就用”的配方你可以根据自己的字段名套进去。5.1 分组填充缺失值与组内累计组内填充是数据清洗里很常见的一步比如同一个门店的客单价缺失了用该门店的历史均值补。用transform一步到位注意fillna要传 Series不能传单个标量否则就是全表同一个值了df[amount_filled] df[amount].fillna( df.groupby(city)[amount].transform(mean) )如果要的是“用组内前一个非空值往后填”那就用ffill但ffill本身不认识分组得先分组再调df[amount_ffill] df.groupby(city)[amount].ffill()组内累计也是transform系的常用动作cumsum、cumcount、cummax都支持分组写法和普通版本一样只是前面多了groupbydf df.sort_values([city, order_time]) df[city_cum_amount] df.groupby(city)[amount].cumsum() df[city_seq] df.groupby(city).cumcount() 1这里有个前提容易被忽略累计类操作依赖顺序。如果原表没按时间排好cumsum算出来的顺序就是错的而且不会报错。所以我在做累计之前一定会显式sort_values哪怕我觉得数据本来就是有序的也要写上这行代码是给自己看的保险。5.2 分组去重取最新一条与多条件分组“每个用户取最近一次下单记录”这个需求几乎每个项目都会遇到。最直接的写法是先排序再groupby().head(1)或者用drop_duplicates配合keeplastlatest ( df.sort_values(order_time) .groupby(city, group_keysFalse) .tail(1) )tail(1)和head(1)的区别就是取最后一条还是第一条配合排序用哪个都行。还有一种写法是drop_duplicates(subset[city], keeplast)这个更快但它的“最后”指的是原表顺序里的最后不是时间上的最后所以必须先把表按时间排好否则结果就是错的。这两个方法的取舍我一般是要保留组内其他计算的中间结果就用groupby纯粹去重就用drop_duplicates。多条件分组在实际业务里更常见比如按“省份 渠道 金额区间”三个维度分组写法就是把三个键放进列表pd.cut生成的区间列也可以直接当键用。唯一的提醒是维度一多结果的组合数会指数增长做报表之前先nunique看一下组合规模别生成出一张几十万行的表把自己吓一跳。5.3 分组结果与原表合并时的索引对齐最后一个高频问题分组聚合的结果怎么合并回原表。正确姿势是用transform直接生成新列而不是agg完再merge。必须merge的时候注意分组键的类型要一致category 和 object 之间合并会报类型不匹配两边都转成同一种再合。还有一种情况是聚合结果的分组键在索引上直接merge会失败得先reset_index()。我一般的写法是聚合时就用as_indexFalse从源头避免这个问题这样结果天然是扁平的后面怎么用都方便。如果你接手了别人的代码看到merge报KeyError第一反应就去看左边那张表的索引是不是 MultiIndex八成就是这个问题。# 推荐从一开始就是扁平结构 stat df.groupby(city, as_indexFalse)[amount].mean() merged df.merge(stat, oncity, howleft, suffixes(, _city_mean))suffixes这个参数也别忘了不写的话同名列会自动加_x/_y后缀后面引用列名的时候很容易写错显式指定能省掉不少麻烦。这套东西用熟之后我个人的体会是groupby的语法其实一页纸就能写完真正需要练的是看到需求就能判断出该用哪个动作、结果长什么形状。这个判断力没有捷径就是拿真实数据多练几遍每次写完抽一组手工核对。我刚开始写的时候也经常用apply一把梭后来被数据量教育了几次才慢慢养成先想形状、再选动作、最后才考虑优化的顺序。这个顺序反过来往往写出来的东西又快又难维护。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →