十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Pandas性能优化实战:从向量化到groupby/merge加速技巧

Pandas性能优化实战:从向量化到groupby/merge加速技巧 做数据分析的人谁没被Pandas慢哭过几百万行数据摆在那一个apply跑两分钟一次merge卡到怀疑人生。网上搜“Python Pandas 加速技巧”出来的帖子大多是零散的参数堆砌告诉你用这个函数用那个方法但不讲透背后的原理。这篇我打算换个方式把我在真实业务里用Pandas处理千万级数据攒下来的加速经验按数据流转的顺序整个捋一遍。不管你是刚入门Pandas试图想搞懂Series和DataFrame关系的新手还是已经被慢查询折磨了一阵子的职场分析师这篇文章都能让你学到能直接上手的操作方法。我尽量把每一步为什么这么做讲清楚同时把那些网上不常写、但踩过坑才知道的细节一并抖出来。先给方案定个调子Pandas加速的本质不是“把循环改好看点”而是尽量把计算压到底层C语言实现的向量化操作用减少Python解释器的参与。只要围绕这个核心去做性能提升往往是一两个数量级的。1. 先搞清楚Pandas到底慢在哪里1.1 用一个生活例子理解“向量化”我去健身房的时候总想假如每次卧推都要先把杠铃片一片一片从架子上搬下来再一片一片装上去那今天也别练了。Pandas慢的场景就特别像这个一条一条遍历行每遍历一行都要经过Python解释器一次Python这层解释执行本身就有固定开销循环次数一多时间全耗在“跑腿”上了。向量化操作完全是另一套逻辑。它不是把每片杠铃都搬一遍而是直接把整根杠铃抬起来。NumPy和Pandas底层用的是C语言实现的连续内存块一次操作处理一整列数据省掉了反复解释、逐个装箱拆箱的损耗。所以一行df[a] df[b]的速度可能是几十行for循环的几十倍。记住这句话能向量化就向量化是Pandas提速的第一性原理。后面所有技巧本质上都是往这个方向上靠。1.2 三种最常见的性能瓶颈我处理过不少同事“跑不动”的DataFrame最终原因逃不出这三类。第一类数据读取太慢。没指定列类型、没筛选列、没提前解析日期导致读进来的表占的内存巨大后续每一步计算都背着沉重包袱。第二类object类型泛滥。Pandas里很多列被自动识别成Python对象类型也就是object这种列每一个元素都是一个Python对象指针内存占用高计算时也没法走底层C的连续内存优化。有时候你以为在做数值运算实际Pandas只能退化成一种很慢的逐元素处理。第三类循环和apply滥用。新手阶段写分析代码遇到稍微复杂点的逻辑第一反应就是写个for循环或者甩一个apply上去。它们确实能解决问题但性能基本等于“照着最慢的路走”。2. 数据读取阶段提速是性价比最高的很多人一上来就优化计算本身忽略了一个事实从文件到DataFrame这第一步往往已经耗掉了总时间的三分之一。2.1 read_csv的这些参数别当摆设pd.read_csv()是最常见的入口但参数用得全不全差别非常大。拿一个千万行的业务表举例# 之前全部默认跑完要50秒 df pd.read_csv(orders.csv) # 之后挑列、定类型、解日期22秒搞定 df pd.read_csv( orders.csv, usecols[order_id, user_id, amount, created_at], dtype{ order_id: int32, user_id: int32, amount: float32 }, parse_dates[created_at] )这里每个参数都有明确目的。usecols直接把不需要的列挡在门外省内存也省解析时间。dtype是很多人忽略的点避免Pandas自动推断类型省掉它内部的类型探测过程同时用int32、float32这种更小精度的类型替代默认的int64、float64内存直接砍半。parse_dates在读取阶段就把字符串转成datetime64后面你要做时间切片、按月聚合效率都高很多。还有个技巧遇到巨无霸文件不敢确定格式时先加nrows1000读一千行看看字段长什么样、有没有脏数据确认逻辑无误再跑全量。这个习惯帮我躲过很多次脚本跑到一半才发现列名对不上的惨剧。2.2 换个文件格式速度可能翻倍我并不是针对CSV但它文本存储加重复解析的短板实在太明显了。如果你的数据要反复读取或者对查询性能有要求强烈建议考虑Parquet格式。import pandas as pd # 把清洗好的DataFrame存成parquet df.to_parquet(orders.parquet) # 下次读取速度肉眼可见地快 df_fast pd.read_parquet(orders.parquet)Parquet是列式存储格式Pandas读取它时可以直接按列加载还能保留数据类型、压缩率也比CSV高好几个档次。同样一份千万行数据CSV有2个GParquet压缩后可能才400M读取时间能从一分钟缩短到几秒钟。尤其在你做数据预处理流水线、中间结果总要用到的时候这个替换非常值得。3. 类型优化一次性降低全表计算成本3.1 为什么object类型就像背着沙袋跑步数据从文件读进来后如果不手动指定类型像“城市”这种字符串列会被存成object看起来好像没啥问题但Pandas内部存的是一个个Python对象指针不是连续内存块。当你对这类列做groupby、去重、排序或拼接时底层都要做指针解引用速度自然提不起来。object还有另一个麻烦它会“污染”算数运算。你本来想算两列的差值如果其中一列是文本数字混合Pandas为了兜底会退化成一种极其缓慢的处理方式。所以数据清洗第一步我通常会打印一下df.dtypes把所有不是目标类型的列挑出来整顿。3.2 astype和category的正确打开方式类型优化有两个主要手段一个是astype调整数值精度和布尔类型# 默认是int64订单量不会超过int32的极限 df[order_count] df[order_count].astype(int32) # 0/1这种标志位用bool只需要1/8的内存 df[is_active] df[is_active].astype(bool)另一个更有杀伤力的是category类型。对于重复度很高的分类列比如“省份”、“城市”、“商品一级类目”把object转成category内存占用下降非常明显尤其适合有大量重复值的字符串列。而且groupby对category列的分组速度也有提升df[province] df[province].astype(category)这里有个细节提醒category类型本质上是整型编码加映射表所以排序的时候顺序可能跟你预期的不一样。如果你指定了顺序要用pd.Categorical(..., categories[...], orderedTrue)显式声明。否则后续画图、排序、对比都可能出现字母序而不是业务序这个小坑我踩过不止一次。4. 不是说什么场景都无脑用apply4.1 先扒一扒apply的底层原理我在给新手讲加速技巧时发现大家都有一个思维惯性遇到复杂逻辑就apply。这个函数确实方便传一个lambda进去逐行处理能把代码写得跟写原生Python一样灵活。但它就是一个包装好的Python循环不断在DataFrame的行之间切换上下文性能只比for循环好一点点。举个例子动态地根据“金额”判断订单等级# 慢apply本质是循环 df[level] df[amount].apply( lambda x: 高 if x 1000 else (中 if x 500 else 低) ) # 快向量化逻辑判断 conditions [df[amount] 1000, df[amount] 500] choices [高, 中] df[level] np.select(conditions, choices, default低)np.select是一次全列比较、一次全列填充底层走C的向量化几十万行数据差距能到10倍以上。这就是我在开头讲的把Python层的循环压掉全部交给底层。4.2 几个能替换apply的高频套路判断型的逻辑用np.select字符串拼接用str访问器直接向量化df[full_name] df[first_name] df[last_name]如果要做条件替换尽量用where、maskdf[discount_price] df[price].where(df[price] 10, df[price] * 0.9)再比如按某列分组后做标准化一定要用transform不要先groupby再apply。transform会保留索引、广播到每一行效率高很多# 慢 df[amount_scaled] df.groupby(user_id)[amount].apply( lambda x: (x - x.mean()) / x.std() ) # 快 df[amount_scaled] df.groupby(user_id)[amount].transform( lambda x: (x - x.mean()) / x.std() )有一种特殊情况我会明确推荐apply就是操作对象不是DataFrame本身而是对每一行的多个字段做一个纯Python计算并且这个计算很难向量化。这时候用apply反而让代码更清晰。但前提是数据量不大几千到几万行可以接受如果是几百万行那就得再去想想能不能先把计算拆成几个向量化步骤。5. 分组聚合和连接合并这才是大杀器数据处理里最吃性能的除了清洗转换就是groupby和merge。这两个操作太常见了一旦数据量上来写法和习惯直接决定了你是等三十秒还是等五分钟。5.1 groupby的加速习惯groupby慢一个很大的原因是类别太多、组合太多Pandas内部要做的分组键匹配和映射就多。实践里我会注意三件事。第一分组键能转category就转category并且用observedTrue关掉笛卡尔组合的预展开。分组字段如果是字符串object类型每条分组都会经历字符串比较转成category后底层是整数编码比较速度快一大截。observedTrue可以防止Pandas把所有类别组合都预生成特别是多个分组维度时这个参数能省掉大量无效组合的内存和计算。# noticed 默认会生成所有可能组合多列分组组合数量爆炸 summary df.groupby([province, category], observedTrue)[amount].agg([sum, mean])第二聚合尽量一步到位。不要groupby三次分别求sum、mean、count用agg把需要的聚合函数一次传进去Pandas可以在一个分组循环里完成所有计算summary df.groupby([user_id], observedTrue)[amount].agg( totalsum, avgmean, countcount )第三能用transform就少用apply这个我在上一章节详细说过这里再强调一次因为groupby场景下的apply性能损失最明显。5.2 merge慢先检查你的连接键连接两个表时如果连接键不是索引Pandas会先进行一次“对齐索引”的隐式操作特别是在连接键是字符串object时匹配开销非常高。我常用的招术是把小表的连接键设为索引然后再合并。# 慢两边都直接merge连接键还得内部排序匹配 merged pd.merge( df_orders, df_users, left_onuser_id, right_onid, howleft ) # 快先让右表把连接键设置成索引 df_users df_users.set_index(id) merged df_orders.join(df_users, onuser_id, howleft)join走索引对齐底层比逐行匹配快了不少。而且df_users.set_index(id)是一次性开销如果这个用户表你反复要跟别的订单表合并这个转换只做一次后续全是红利。另一个容易忽略的点是连接顺序。数据量差距悬殊时left join和right join的时间差别明显。我会把大表放左边做驱动表小表放右边做匹配这样可以减少Pandas内部构建哈希索引的扫描范围。虽说不至于天翻地覆但在千万级数据下这个顺序能省几秒甚至十几秒。6. 循环逃不掉时这才是正确解法6.1 iterrows是性能杀手但有替代品每次培训的时候大家写复杂逻辑都会用iterrows。说实话我看到df.iterrows()基本已经预见到这段代码在百万行上得跑好几分钟。iterrows每取一行就产生一个Series里面塞了一堆索引信息、类型信息开销大得吓人。如果你确实需要逐行处理值请试试itertuples()。它把每一行包装成一个具名元组不创建Series对象速度能比iterrows快出5到10倍。虽然它还是Python循环但至少比iterrows那种“豪华打包”省事多了。for row in df.itertuples(indexFalse): if row.amount 1000: # 处理逻辑 pass再进一步如果循环里做的是数值计算完全可以把DataFrame的列转换成NumPy数组再用Python循环直接遍历数组避开Pandas一整套行包装机制。这个技巧看起来很朴素但数据量大的时候会明显快不少。6.2 numba是终极武器如果循环逻辑真的很复杂没法向量化那我会搬出Numba。它用JIT编译技术把Python函数在第一次调用时编译成机器码之后的循环速度接近C语言原生的级别。import numba import numpy as np numba.jit(nopythonTrue) def compute_score(amount, count): total 0.0 for i in range(len(amount)): if amount[i] 1000: total amount[i] * count[i] * 0.8 else: total amount[i] * count[i] * 0.5 return total amount_arr df[amount].to_numpy() count_arr df[count].to_numpy() result compute_score(amount_arr, count_arr)nopythonTrue表示强制不调用Python解释器一旦能编译成功那个循环跑起来爽到飞起。要注意的是Numba不能传Pandas Series进去必须先转NumPy数组。如果函数里用到了非数值类型的字符串操作Numba支持力度有限需要谨慎最好先拿一小部分数据测试一遍再上全量。7. 内存扛不住也是慢怎么一并解决7.1 分块读取是超大数据集的现实解法有时候不是Pandas算不动是电脑内存压根扛不住整个文件。数据20个G物理内存16个G你再怎么优化类型都绕不开“装不下”这个事实。这时候就轮到分块处理登场了。chunk_iter pd.read_csv( huge_orders.csv, chunksize500_000, # 每块50万行 usecols[user_id, amount], dtype{user_id: int32, amount: float32} ) results [] for chunk in chunk_iter: # 对每块做处理比如分组求和 results.append(chunk.groupby(user_id)[amount].sum()) final_result pd.concat(results).groupby(level0).sum()这里的关键是最后那个concat加再次groupby因为单块聚合的结果不是全局结果需要把所有块的结果汇总后再做一次聚合。这种“分块-局部处理-合并全局”的模式是处理超大型CSV的惯用手法。7.2 先算算你的DataFrame到底吃多少内存很多人不关心内存用量直到程序跑到一半被系统杀进程。不看不知道看了之后你会对优化方向有清晰的认知。# 只看总体内存 print(df.info(verboseTrue)) # 更细致的内存占用 print(df.memory_usage(deepTrue))memory_usage(deepTrue)会计算object列里字符串真正的内存占用用完之后你会立刻明白为什么几列字符串能把内存耗光从而更坚定地去做category转换和dtype下调。我习惯在主流程中间打印一次内存报告确认每一步的类型优化确实生效了这个习惯帮我避免了好几次线上内存爆掉的事。8. 常见报错与排查技巧速查8.1 我踩过的几个典型坑先说一个大家经常会遇到的报错AttributeError: module pandas has no attribute core。这类问题大多是Pandas版本过旧或者安装时出了差错某个组件导入不完整。解决办法很简单升级Pandas或者干脆重装一遍pip install --upgrade pandas再说一个类型转换的坑。value_counts()返回的索引很多时候是object你以为可以当整数直接用但去loc索引的时候却报错。解决方案是显式转换.index .index.astype(int)别偷懒。8.2 排查瓶颈的一般思路一个Pandas流程慢不要先急着改代码先用时间分析把瓶颈找出来。我常用的方法是在关键节点打点import time start time.time() df pd.read_csv(data.csv, ...) print(f读取耗时: {time.time() - start:.2f}s) start time.time() df group_res df.groupby(...).agg(...) print(f聚合耗时: {time.time() - start:.2f}s)哪个环节耗时最长就针对性地用上面的技巧去优化。这个“先测量、后优化”的顺序很重要很多同学一上来就逼自己用各种复杂技巧结果优化的地方根本不是瓶颈纯属白费功夫。8.3 一个低成本的后续扩展方向Pandas加速技巧学会了之后如果业务数据还在继续膨胀你可以顺着两个方向进阶一是把Pandas的接棒替换成Polars它底层用Rust实现多核并行性能比Pandas还要猛一截二是学习Dask它通过分布式并行把Pandas的接口延展到集群环境处理几十G的表格也能撑得住。不过这些工具的接口跟Pandas都有一些细微差别上手前最好先在本地搭小数据量跑通流程免得直接被语法差异劝退。我自己在处理数据的时候还有一个习惯任何一次复杂的提速优化做完后都会把前后耗时记下来哪怕只是一句话备注。这些记录攒多了再遇到相似的慢查询一眼就能判断出该从哪一步下手而不是又从头开始试各种技巧。提速这事本质上就是把底层原理摸透之后照着最省力的路径走一次比一次准。
返回列表