资讯动态

Pandas数据处理提速实战:从读取到聚合的全链路优化

发布时间:2026/10/20 9:32:22 来源:尧图企业网站定制
上周帮一位做用户运营的同事优化日报脚本同一个统计任务他原来的脚本要跑22分钟我只做了三个改动——改读取方式、改数据类型、改聚合写法就跑到了45秒。他第一反应是问我是不是偷偷加了什么并行框架其实一个第三方库都没多安装全是我把Pandas的底层行为琢磨明白之后做的小调整。这就是我想写这篇文章的原因。很多人学Python数据处理时习惯照着教程敲一遍Pandas的API就觉得自己会了但真到了生产环境——每天几百MB甚至几个GB的日志、报表、采样数据——才发现脚本要么跑得极慢要么直接内存爆炸。所谓高效从来不是代码写得短而是每一个环节都清楚为什么会慢、为什么会占内存、有没有更省的做法。这篇文章不会教你什么是DataFrame这类基础概念而是按数据从磁盘到内存、从清洗到聚合、从单核到多核的完整链路讲讲我在真实项目中反复验证过的提速手段。适合已经能用Pandas做简单分析、但一遇到大文件就卡壳的同学也适合想把手头脚本再压一压性能的开发者。1. 先别急着写业务代码IO与类型推断才是一切的起点很多脚本的慢其实在数据还没有真正开始处理之前就已经输了。你写的第一行代码往往是pd.read_csv(raw_data.csv)你觉得这只是一句把文件读进来但在Pandas内部这行代码承担的工作量远超你的想象。先看CSV这个格式。它本质是纯文本每一行都是一个字符串。Pandas要做的第一步是把整个文件按行拆开第二步是逐块猜测每一列的类型第三步才是把文本转换成真正的NumPy数组。这三步合在一起性能开销极不稳定。文件越大、列越多、脏数据越多类型推断就越慢而类型推断一旦出错后面所有清洗步骤都要跟着返工。我做过一个很直观的对比一份1GB左右、20列的CSV在我机器上直接read_csv要七八十秒如果把它转成Parquet格式后续再读取只需要四五秒。差距不是一倍两倍而是十几倍。所以我把第一条经验放在这里文件格式选对了性能优化的地基就打好了。1.1 文件格式的冷热差异CSV输在起跑线上CSV的优势是通用、人眼可读、任何工具都能打开。但在自动化数据处理流程里这些优势反而是性能负担。文本解析要把每个字段当成字符串处理遇到数字还要再做一次转换更麻烦的是CSV没有内嵌的类型元数据所以Pandas不得不扫描数据来猜测每一列是什么类型。而Parquet是一种列式二进制格式它在写入时就把每一列的数据类型、压缩编码全部固定下来了。读取时只需要按列加载用不到的那些列甚至可以跳过根本不必进入内存。Feather同理它是Arrow格式的轻量封装读起来也很快但生态上Parquet更通用、压缩率更好。所以我给日常项目的建议是原始CSV留一份做归档处理层面统一用Parquet作为中间格式。第一次读入CSV后立刻转存后续所有脚本都从Parquet读不仅是快还能让团队里其他人不再用read_csv硬扛大文件。1.2 列裁剪和类型预设提前替Pandas把功课做完如果暂时离不开CSV那至少要在读取阶段替Pandas省掉它默认要做的那些功课。这里面有两个参数最关键usecols和dtype。很多人的read_csv是整表读入哪怕最终只用其中三列。每次读入几百列无用数据内存和IO时间都白白烧掉了。usecols能直接指定需要的列让那些永远用不到的字段在读取阶段就被忽略掉。这不仅是省内存也是省时间因为Pandas不需要解析那些列的内容。dtype的作用是跳过类型推断。你要知道类型推断本身是一个非常耗时的过程而且经常推错。比如一列ID如果中间出现了缺失值Pandas可能把它从int64升级成float64后面所有操作都要在浮点数的世界里进行慢且怪。再比如日期列Pandas默认先当字符串读进来之后你再用pd.to_datetime()转换等于解析了两遍。我的习惯是这样写import pandas as pd cols [user_id, event_time, event_type, amount] dtypes {user_id: int32, event_type: category} df pd.read_csv( raw_log.csv, usecolscols, dtypedtypes, parse_dates[event_time], encodingutf-8, )event_type在业务上通常是几十种固定取值直接声明成category读进来就是压缩后的整数索引后续做groupby、value_counts都快。amount这种数值列如果有缺失我会先让它保持float64清洗阶段再决定是补零还是剔除。类型预设的粒度不需要太细但至少要把明显是ID、类别、日期的那几列管住。1.3 读取后立刻转存一次Parquet转换长期受益如果你的项目里有一份每天都会用到的数据表强烈建议把它从CSV转成Parquet。这不是什么高深技巧但收益极大。我一般在拿到新数据源时会先跑一次这样的转换df pd.read_csv(raw_log.csv, dtypedtypes, parse_datesdates) df.to_parquet(raw_log.parquet, enginepyarrow, indexFalse)之后所有读取都改成pd.read_parquet(raw_log.parquet)。第一次转换多花几十秒换来的是之后每次读取快十几倍。enginepyarrow是目前最稳定的Parquet后端正好也能和后面要讲的Polars共享Arrow生态后面你想换引擎做分析时文件格式都不用重新处理。注意Parquet文件不适合频繁追加。如果你的数据是不断增长的流式日志更好的做法是每小时或每天生成一个Parquet分片读取时用pyarrow.dataset统一加载多个分片而不是反复打开同一个文件往里写。2. 大文件分块读取不炸内存也能算总量的正确姿势就算格式优化好了总有那么一些历史遗留的巨型CSV或者你根本没机会转存的数据源必须硬着头皮直接读。这时候最让人头疼的问题不是慢而是内存爆掉。我有一次处理一份15GB的线上行为日志机器内存32GB。我一开始觉得32GB肯定够了吧结果read_csv还没跑完就收到了MemoryError。原因很简单文本解析阶段Pandas会在内存里同时保留原始字符串缓冲、临时对象和最终数组峰值占用可能是最终DataFrame的三到五倍。所谓文件15GB内存32GB其实根本不够。这种时候就得学会分块读取。2.1 从MemoryError说起分块读入的原理与适用边界read_csv里有一个chunksize参数。传了它之后返回的就不是一个DataFrame而是一个迭代器每次迭代给你一个固定行数的分块DataFrame。比如chunksize100_000就是每次处理10万行。这些分块是逐个创建的处理完一个就被回收一个内存峰值因此被压得很低。但分块不是万能药它只适合可以增量统计的计算。什么是增量统计sum、count、min、max、mean、行数、唯一值数量这些都可以分块处理再合并。但如果你要做全局排序、跨块的merge、或者对整张表做复杂的窗口函数分块就会非常痛苦。我的判断标准很简单文件大小超过可用内存的三分之一才考虑分块否则全量读入通常更快、代码也更清爽。2.2 分块后的聚合怎么落地Counter、groupby与增量更新分块之后最简单的场景是求总和import pandas as pd total_amount 0 for chunk in pd.read_csv(big_log.csv, chunksize100_000, parse_dates[event_time]): total_amount chunk[amount].sum() print(total_amount)稍微复杂一点按类别统计数量可以用collections.Counter增量累加from collections import Counter counter Counter() for chunk in pd.read_csv(big_log.csv, chunksize100_000): for k, v in chunk[event_type].value_counts().items(): counter[k] v如果要做真正的groupby多列统计我会把增量逻辑封装成一个循环里的小函数每次对块做groupby后把结果累加到一个总结果DataFrame里但前提是每个块的groupby keys要一致。你也可以用pd.concat把多块结果先攒起来再统一groupby但那个方案在块数很多时会重新引入内存压力不如边读边累加。还有一个小坑分块后如果做浮点数累加不同块的累加顺序会导致最后结果有极小的精度差异。对金额类字段我建议先用整数表示分或者最后统一做四舍五入避免报表上出现莫名奇妙的0.000001误差。2.3 别把分块当银弹该全量读的时候别犹豫我见过一些同学不管文件多大一律chunksize走起。这个习惯不好因为每生成一个分块DataFrame都有固定开销解析头部、分配内存、类型转换。文件明明只有200MB你的机器有16GB内存全量读2秒搞定分块读反而可能变成5秒甚至更慢。什么时候必须分块文件超过可用内存的一半、或者你对内存占用完全没把握的时候。什么时候坚决不分块需要做全局窗口函数、跨行比较、顺序依赖很强的特征工程时分块会让代码复杂度爆炸。记住分块是一个能躲就躲的方案而不是默认方案。如果数据还能转Parquet优先转只有在又大又不能转的极端情况才轮到分块出场。3. 告别apply找回向量化数据清洗提速的核心逻辑读入数据之后紧接着就是清洗。很多人的清洗阶段长满了df.apply(lambda x: ...)原因很简单这种写法最符合人的直觉——我每一行都要做某一个判断那就让每一行都执行一遍这个函数。但这条路恰恰是最慢的。Python的apply本质上是逐行调用Python函数每调用一次都要经过完整的解释器、类型检查、函数调用栈。这不是Pandas故意为难你而是Python本身的设计决定的。3.1 一个简单的实验Vectorized写法比apply快多少我最近刚做过一次测试。100万行数据两列整数a和b我要生成一列a b。方式一是applydf[s] df.apply(lambda row: row[a] row[b], axis1)方式二是向量化df[s] df[a] df[b]你猜差距多少差了大约70倍。apply版本跑了接近1.2秒向量化版本5毫秒都不到。为什么因为df[a] df[b]在底层是把两个NumPy数组交给C语言级别的循环去批量相加一次循环处理成千上万个元素而apply是一行一行地在Python解释器里跑。这还只是一个加法。如果清洗逻辑里再嵌套条件判断、字符串处理、日期转换apply的劣势会被放大得更离谱。所以我的底线是凡是能用向量化实现的逻辑绝对不用apply。3.2 字符串清洗的隐藏陷阱正则、category与执行顺序字符串列是所有类型里最拖累性能的。一个Python字符串对象本身就包含指针、长度、编码信息存储开销大比较和替换又需要逐字符操作。很多人对文本列做清洗时会写一连串.str.replace()每一行都可能匹配一个正则表达式这在大数据量下会非常慢。几个我验证过的优化手段第一正则表达式先编译再使用。.str.replace虽然内部会做缓存但如果我们每次都传一个字符串pattern它仍然要先解析一次。预编译会让循环里的多次调用减少一些额外开销。import re pattern re.compile(r[^\u4e00-\u9fa5a-zA-Z0-9]) df[clean_text] df[text].str.replace(pattern, , regexTrue)第二能先转category的字符串列尽量先转。如果一列取值的重复度很高比如只有几十种来源渠道转成category之后后续所有的groupby、value_counts、等值筛选都变成整数比较而非字符串比较性能提升非常明显。第三注意操作顺序。我见过有人在字符串清洗流程里先把整列转成category然后又去.str.strip()结果发现报错才反应过来category没有字符串方法。正确顺序一般是先做字符串清洗最后再转category。顺序错了轻则报错重则白干。3.3 复杂条件赋值用np.select改写到一颗子弹都不浪费清洗阶段最容易让新手破防的场景是多条件赋多值。比如根据金额大小、是否VIP、是否新客打一个订单等级标签。很多人觉得这逻辑太绕不用apply没法写。其实有更好的选择numpy.select。我有一段真实改写的例子import numpy as np import pandas as pd conditions [ (df[amount] 1000) (df[vip] 1), (df[amount] 1000) (df[vip] 0), (df[amount] 100) (df[vip] 1), ] choices [大额会员单, 大额普通单, 小额会员单] df[order_level] np.select(conditions, choices, default其他)这段代码没有任何Python循环conditions里的每个布尔Series都是向量化比较的结果np.select一次性完成整列赋值。原来我用apply写这个逻辑大概写了30行运行时间几百毫秒改写之后只有不到10行运行时间降到几毫秒。还有一个细节条件里同时包含多个布尔表达式时要用而不是and。因为是逐元素的与返回布尔Seriesand只能判断整个对象是否为True在向量化场景里会直接报错。4. 内存优化不是玄学降级、去重与生命周期管理数据分析里有一句老话慢一点还能等内存爆了直接没得玩。当你想分析的数据量超过可用内存时任何优雅的写法都无济于事。所以内存优化不是锦上添花而是能不能跑起来的关键。有人觉得内存优化很玄学其实它就三件事让每一列占用更少的字节、去掉重复的数据、及时释放不再需要的对象。4.1 数值类型降级从结果反推而不是凭直觉拍脑袋Pandas默认把整数设为int64、浮点数设为float64这是为了保证精度和跨平台一致性。但业务数据往往根本不需要这么宽的取值范围。订单量、年龄、库存数、天数很多数值的分布可能只用到几千以内。用int64存只需要几千的整数等于一个车位停了一辆加长卡车。以1000万行数据为例一列int64占80MB降级到int32就是40MB降到int16只有20MB。内存下来了访问速度通常也会上去因为更小的数据类型更利于CPU缓存。我一般会写一个通用的降级函数在数据读入后跑一遍import pandas as pd def downcast_numeric(df): for col in df.select_dtypes(include[int64, float64]).columns: if df[col].dtype int64: c_min, c_max df[col].min(), df[col].max() if c_min 0: if c_max 255: df[col] df[col].astype(uint8) elif c_max 65535: df[col] df[col].astype(uint16) else: df[col] df[col].astype(uint32) else: if c_max 127 and c_min -128: df[col] df[col].astype(int8) elif c_max 32767 and c_min -32768: df[col] df[col].astype(int16) else: df[col] df[col].astype(int32) # 浮点列通常降到float32够用 return df注意两点一是有负数的列要用signed类型别一上来就uint8二是如果后续要和某个精度要求极高的库对接比如某些金融计算float32可能会引入精度损失需要谨慎。4.2 category的甜与苦重复字符串先瘦身再说内存优化的第二大杀器是category。它的原理很简单把一列字符串去重成一个字典然后把每个单元格存成字典的整数索引。省份、渠道、事件类型、商品类目这种取值很少但重复很多的列转成category后内存能降一个数量级。但有三个容易踩的坑。第一唯一值很高的列不要转category。比如用户ID如果一列有上百万个不同的字符串category内部要维护一个巨大的映射表不仅不省内存在做groupby或value_counts时还会更慢。用df[col].nunique() / len(df)这个比例可以判断通常小于5%甚至1%才值得转。第二category的排序规则和普通字符串不一样。普通字符串排序是字典序category排序是按照类别内部的顺序通常是你第一次见到该值的顺序。如果你转完category后sort_values出来的结果可能和预期不一致先astype(str)再排序或者用cat.reorder_categories显式指定顺序。第三category与外部系统交互时容易出问题。有些数据库驱动或Excel导出不支持category类型所以在输出之前记得转回普通字符串。4.3 del之后真的释放了吗中间变量、gc与memory_profiler体检我遇到过一个问题脚本跑着跑着内存越来越高明明已经del了中间变量。后来排查才发现问题出现在循环里。每次循环我都把处理结果append到一个大list里所有小块DataFrame其实都活得好好的循环结束后才一次性变成大DataFrame内存当然下不去。正确的做法是能不创建中间DataFrame就不创建。比如df[df[a] 0][b].sum()会先创建一个筛选后的完整DataFrame再取列、再求和改成df.loc[df[a] 0, b].sum()就只产生一个布尔索引和一个临时Series内存和速度都更优。del之后内存也不一定立刻释放。Python的引用计数机制会在引用归零时释放对象但底层内存的归还给操作系统是另一回事。在长时间运行的脚本里偶尔调一下gc.collect()有好处但别把它当成万能药。真正有效的还是别制造垃圾。如果想知道到底哪一行代码吃掉了内存建议给脚本套一层memory_profilerpip install memory_profiler在你怀疑有问题的函数上加上profile装饰器运行后可以看到每一行代码执行前后的内存增量。我每次排查内存泄漏都用它定位到具体行效率远高于肉眼猜。5. 多核红利怎么吃multiprocessing与Polars的务实比较单机数据分析做到一定程度瓶颈就变成CPU只有一两个核在工作。Pandas的很多操作默认是单线程的尤其是apply和字符串处理大型DataFrame要跑很久。这时候有两种主流出路一是自己动手用multiprocessing把任务拆给多核二是换个更懂多核的引擎比如Polars。5.1 进程池实战术大文件并行处理的两种打开方式因为Python有GIL线程在CPU密集型计算上基本无法并行所以要用multiprocessing开进程。进程之间内存不共享每个进程有自己的Python解释器和NumPy环境适合把一个大任务拆成若干独立子任务。最简单的方式是把数据先分块再把分块索引分配给进程池最后汇总结果from multiprocessing import Pool def process_one_chunk(args): file_path, start, end args df pd.read_csv(file_path, skiprowsrange(1, start 1), nrowsend - start) return df.groupby(event_type).size() if __name__ __main__: chunks [(file_path, i * 200_000, (i 1) * 200_000) for i in range(50)] with Pool(processes8) as pool: results pool.map(process_one_chunk, chunks) final pd.concat(results).groupby(level0).sum()这种写法能跑通但skiprows很笨重每次都要从头扫描文件跳过前面的行文件越大越亏。更优的方案是先把CSV转成Parquet再用pyarrow.dataset按行组并行读取或者干脆在转换阶段就把数据切分成多个Parquet文件每个进程独立读一个文件。并行化的核心不是代码里出现Pool而是保证每个子任务尽量不依赖其他任务的输出。如果子任务之间还要交换数据那并行方案基本是自找麻烦。5.2 Polars的惰性与线程池换个引擎常见算子快好几倍如果你不想手动管理进程池Polars是另一个务实的选择。它基于Apache Arrow和Rust实现在架构层面就支持多线程默认就能把多个CPU核心利用起来。更重要的是Polars的惰性查询计划会自动做优化比如你在groupby之前只需要三列Polars在扫描阶段就会只读那三列比如你有filter再groupby它会尽量把过滤条件下推到更早的阶段减少中间数据量。我拿同一个几GB的Parquet文件做过对比同样是筛选分组聚合Polars的惰性模式比Pandas的全量模式快三四倍是很常见的。用法也很直观import polars as pl df pl.read_parquet(access_log.parquet) result ( df.lazy() .filter(pl.col(event_time).dt.hour() 18) .group_by(event_type) .agg(pl.len().alias(cnt), pl.col(amount).sum().alias(total_amount)) .collect() )注意它没有index的概念没有df[(df[a] 0)]这种链式筛选而是用filter方法。刚迁移时会有不少语法细节要适应但核心哲学和Pandas是相通的列操作优先于行操作。5.3 迁移评估清单哪些场景值得从Pandas换到Polars不是所有Pandas项目都应该立刻迁到Polars。我给自己定了一个评估清单文件是否经常超过1GB是值得试否Pandas可能已经够用。主要操作是否集中在groupby、join、filter、sort这类可并行算子是收益最大如果是复杂的自定义逐行逻辑Polars也没辙。团队里其他人是否熟悉Polars如果只有你一个人用将来交接成本会很高。下游生态是否依赖Pandas对象比如某些绘图库或机器学习库Polars需要.to_pandas()转换这个转换有开销但通常可以接受。我的倾向是新项目、数据量大、无历史包袱直接Polars起步老的Pandas项目只要没到性能瓶颈不要为了新而新。两者完全可以混合使用Polars处理完.to_pandas()给上下游。6. 完整实操复盘把一天10GB访问日志加工成用户行为日报前面的技巧单独拎出来都很有用但只有串在一起你才会知道真实项目的提速是怎么发生的。我用一个完全虚构但非常典型的场景来复盘某平台有10GB左右的一天访问日志需要产出第二天的用户行为日报。6.1 需求拆解日报的四个指标分别要什么底层数据原始日志是CSV格式字段包括user_id、event_time、event_type、page_url、amount。其中event_type有浏览、加购、下单、支付等取值amount只在下单和支付事件里才非空。日报要求输出四项指标当日活跃用户数UV各event_type事件数量分布按小时统计的流量曲线用户消费金额分层我把任务拆成四大步转格式、做清洗、算指标、导报表。每一步都直接对应前面讲过的优化手段。6.2 最终代码链路从原始日志到每日报表的必经之路第一步先把原始CSV转成Parquet。10GB的文件直接read_csv有爆内存风险所以我用分块方式转换import pandas as pd import pyarrow.parquet as pq import pyarrow as pa cols [user_id, event_time, event_type, page_url, amount] dtypes {user_id: int32, event_type: category} writer None for chunk in pd.read_csv( access_log_20250401.csv, usecolscols, dtypedtypes, parse_dates[event_time], chunksize200_000, ): if writer is None: writer pq.ParquetWriter( access_log_20250401.parquet, pa.Schema.from_pandas(chunk), ) writer.write_table(pa.Table.from_pandas(chunk)) if writer: writer.close()这一步大概耗时一分多钟之后所有指标计算都不再碰原始CSV。第二步从Parquet读取并做清洗。读取时我直接减掉不需要的page_url列压缩一次内存df pd.read_parquet(access_log_20250401.parquet, columns[user_id, event_time, event_type, amount]) df[event_type] df[event_type].astype(str).str.strip().astype(category) df[amount] df[amount].fillna(0).astype(float32)注意event_type我做了先转字符串再清洗再转category的顺序处理这是前面提到的操作顺序陷阱。第三步计算四个指标uv df[user_id].nunique() event_dist df[event_type].value_counts() hourly_curve ( df.set_index(event_time) .resample(1h)[user_id] .count() .reset_index() ) amount_bins pd.cut(df.loc[df[amount] 0, amount], bins[0, 100, 500, 1000, 99999]) amount_level df.loc[df[amount] 0].groupby(amount_bins, observedTrue).size()nunique在几千万行上是一个成本较高的操作但配合user_id已经是int32比默认的int64省了整整一半内存速度也更快。resample做小时曲线是Pandas比较高效的做法比手动切小时再分组快。6.3 提速效果对比表与当天踩掉的三个坑当天优化结束后我把关键阶段的耗时做了记录大致是这样阶段优化前优化后主要手段数据读取90秒6秒转Parquet 列裁剪类型与内存内存峰值9.4GB内存峰值2.1GBcategory 数值降级清洗事件类型/金额35秒4秒向量化替代apply四个指标聚合160秒13秒value_counts/resample/groupby合计约5分钟约23秒完整链路优化不过说实话当天也踩了三个坑给我留下的印象比提速本身更深。第一个坑是时区。日志里的event_time有一部分带了偏移后缀比如2025-04-01 08:30:0008:00另一部分是纯北京时间。我直接按字符串解析结果按小时分组时发现凌晨和晚上的流量数字对不上最后统一转成UTC再显示为北京时间才解决。第二个坑是BOM头。CSV文件第一个字符是UTF-8的BOM列名里带了一个看不见的\ufeff导致df[event_type]第一次筛选时报错。后来我在读CSV时用了encodingutf-8-sig这个问题才算根治。第三个坑是金额精度。amount字段在分块累加后总是多出零点几的偏差最后我把金额先转成以分为单位的整数再做聚合最后一步再转回元从此再没出过误差。折腾完这一整套之后我的体会是所谓高效的Python数据处理本质上不是某个框架比另一个框架强多少而是你愿不愿意在每个环节都搞清楚它为什么要花这个时间、占这个内存。把读取方式改对、把类型管住、把循环写成向量化、把能并行的事情交给引擎这四个动作做完大部分脚本的性能问题就已经解决大半了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑