资讯动态

Pandas数据处理全流程:读取、清洗、聚合与优化

发布时间:2026/10/22 13:28:26 来源:尧图企业网站定制
帮某业务团队处理一批历史订单数据时我拿到文件后的第一个动作不是写Pandas读取代码而是先把CSV文件拖到编辑器里人工扫一遍前几十行。很多人觉得这一步多余但数据完全是脏乱差的时候数据库里倒出来的字段类型、空值规则、日期格式统统要靠肉眼确认。Pandas确实是个非常趁手的数据处理工具从读文件、探查、清洗到聚合统计都有成熟的接口但工具给得越多需要做的决策也越多缺失值该不该删、重复记录按什么维度判断、多表合并用哪种方式……这篇内容我想梳理一下自己在Pandas开发中的完整套路。不谈太高深的算法也不只是罗列API而是围绕真实数据处理流程把读取、探查、清洗、分组聚合、性能优化这几个环节里最容易栽跟头的地方拆开讲补上对应的排查思路。适合正在学习Pandas、或者已经写了几段数据处理脚本但经常被各种返回值绕晕的开发者。1. 读取阶段就把坑埋掉文件加载时不建议等到清洗再补1.1 读文件之前先做的三个动作很多人拿到一个 csv 或 xlsx 之后第一行就是pd.read_csv(data.csv)。这个习惯不是不行但你大概已经踩过这样的坑某列明明全是销售日期读进来却是字符串某个字段里混着0和NULL后面统计的时候 sum 出来差一大截还有第一列列名带着一个看不见的\ufeff导致按列名筛选怎么做都不对。我现在的习惯是读文件之前先做三件事看文件大小、看前几行、看分隔符和编码。在命令行里操作一下ls -lh orders_2024.csv head -n 5 orders_2024.csv file orders_2024.csv其中ls和head一般不会有惊喜file这个命令却经常能提前暴露编码问题。比如某个业务导出的文件权限系统显示是 UTF-8实际用file查出来是ISO-8859这时候如果你按 UTF-8 去读Pandas 会给你一堆乱码或者直接报错。先花三十秒确认这些基础信息能省掉后面一小时的排查时间。另一个值得做的动作是把表头打印出来看一眼。很多时候表头并不规范比如“订单号”和“订单编号”同时在两个文件里出现或者某列名带空格、带括号如果你不改后面所有按列名操作的代码都会变得脆弱。我发现不少开发者习惯拿到原始表头就直接用后来字段一多、筛选一变代码里到处是df[订单号 ]这种带着隐形空格的写法调试起来非常痛苦。1.2 dtype、parse_dates与内存占用的配合这一步的目标很简单让Pandas在读取时就获得正确的数据类型而不是等进了内存再反复切。看一个来源于真实项目的例子import pandas as pd orders pd.read_csv( orders_2024.csv, dtype{ order_id: str, user_id: str, status: category, }, parse_dates[order_time, pay_time], low_memoryFalse, )这里指定order_id和user_id为str是很重要的一步。如果不指定Pandas可能把一长串数字ID读成int64表面上看没毛病但一旦ID列出现前导零或者超过15位你会看到数值被转成科学计数法数据就此失真。类似的还有手机号、银行卡号这类字段都应该在读取时用dtype{id: str}锁住。parse_dates会把指定的列解析为datetime64类型。解析成功后你可以直接用.dt.year取年份也能在groupby里按时间频率聚合。这里建议在读取时就做好不要在后期用pd.to_datetime再转因为大批量转换会额外消耗内存和时间。low_memoryFalse则是用来避免Pandas在读取大文件时由于分块推断类型而把同一列在不同区块推断成不同类型最终导致整列变成object的问题。现在很多人不太关心这个参数但遇到文件上百万行、列数几十个时这个坑特别容易踩。1.3 encoding与分隔符里的隐蔽细节编码问题主要出现在中文环境。CSV文件常见编码是utf-8、gbk、big5或utf-8-sig而Pandas默认的encodingutf-8只对第一类有效。如果你在读取时看到UnicodeDecodeError: utf-8 codec cant decode byte 0xc4...基本可以判断文件是GBK或其他编码。这时候改成encodinggbk通常能解决。还有一种情况文件本身是UTF-8但在Windows下用Excel另存过文件开头会带上BOM读进来的第一个列名会变成\ufeff订单号。解决办法很简单用encodingutf-8-sig读取这一个参数能消掉BOM。分隔符的坑出现得也不少。有些系统导出的不是纯逗号分隔而是制表符或多字符分隔符比如df pd.read_csv(report.txt, sepr\t)用正则表达式r\t可以一次吃掉多个连续制表符比sep\t更稳妥。另外如果文件内容里包含逗号但用引号包裹了字段Pandas的quotechar和quoting参数可以解决但更常见的做法是在读取前先看一眼原始文件判断一下哪些符号是真正的分隔符。读取阶段做得足够细致后面清洗的代码会短很多。这也是我一直主张读取阶段必须留下显式参数的原因——你不光是在给Pandas传参更是在把对数据格式的判断固化到代码里下次换一批数据时只要看一眼读取参数就能知道当时遇到了什么。2. 数据探查的完整步骤从shape到重复值的一整套动作文件读进来了接下来很多人习惯直接df.head()看两眼就进入处理但我建议你至少跑一组探查命令把数据的基本面掌握清楚这部分代码几乎是固定模板。2.1 shape、columns和dtypes是在确认数据的“边界”下面这套就是我对任何新数据源都会先跑一遍的print(df.shape) # (行数, 列数) print(df.columns.tolist()) # 列名清单 print(df.dtypes) # 每列类型 print(df.info()) # 每列非空数量与内存占用shape能让你快速判断数据规模和处理方式是否匹配。如果只有几千行你大可以随便玩如果已经是几百万行那后面每一步操作都要考虑内存和速度。columns.tolist()比直接打印df.columns更友好因为它是列表形式不会出现Index([...])这样绕眼的显示省得你还要自己脑补列名。df.info()是这里信息量最大的一个命令。它会把每列的非空值数量、数据类型、内存占用一并列出来。我最关心的其实是Non-Null Count那一列如果某列的非空数量明显小于行数说明它存在缺失如果某列全部非空那后面清洗时不用在这列上花太多时间。这里还有一个容易被忽略的点df.info()显示的是每列“非空”的数量但不会告诉你空字符串有多少。也就是说“空字符串”在Pandas里是并不会被isnull()识别为缺失。所以探查阶段最好再补一个for col in df.columns: if df[col].dtype object: empty_count (df[col].astype(str).str.strip() ).sum() if empty_count: print(col, 空字符串数量:, empty_count)这步不做的话后续用dropna()想删空值可能只删掉了NaN漏掉一片真正的空白文本。2.2 describe与value_counts是判断分布的利器只看着列的类型和空值还不够你还需要知道每一列大概长什么样。df.describe(includeall).Tdescribe(includeall)会同时展示数值列和文本列的统计信息比如数值列的最小最大值、标准差文本列的 unique 数量和 top 频率。它能快速帮你发现两类问题一类是数值列里有明显异常值比如订单金额出现负数另一类是文本列里枚举值太多太杂后面分组统计会很痛苦。文本列还需要单独用value_counts看频率分布df[status].value_counts(dropnaFalse) df[channel].value_counts(normalizeTrue)value_counts是最容易被低估的一个函数。我经常用它在探查阶段定位类似“状态字段里居然混着pending和Pending”“城市列里有深圳市这种带尾随空格的值”这种问题。如果你这时候把normalizeTrue打开还能看到每个类别占比对于判断字段是否需要清洗、是否值得做分箱或编码都有直接帮助。2.3 多级表头和列名重名探查阶段的特殊关卡有些Excel文件传入Pandas时表头并不是在第一行或者存在合并单元格。比如某银行账单第一行是机构名称第二行才是真正的字段名。这种情况下df pd.read_excel(bill.xlsx, header1)header1指定从第二行开始作为列名。如果你不加这个参数第一行“机构名称”会变成第一列列名后面整个数据结构都错位了。还有一种场景是列名重复Pandas会自动在重复列名后加.1、.2后缀如果不提前发现后面按列名操作时经常会选到错误的一列。列名乱象在探查阶段顺手处理掉是最省力的。我的做法是统一小写、去空格、取标准命名df.columns df.columns.str.strip().str.lower().str.replace(r\s, _, regexTrue)这行代码把列名全部变成小写加下划线的风格之后所有后续代码都基于这套列名写可读性和可维护性都会好很多。3. 清洗数据的高频坑缺失值、重复值与类型转换3.1 缺失值不是单纯“删掉”或者“填上”缺失值处理是Pandas开发中决策成本最高的环节。dropna()和fillna()这两个接口很多人都会用但真正的问题是缺失值背后的业务含义是什么我把缺失分成三类。第一类是真缺失比如订单表里没有支付时间的订单还在待支付状态这种缺失是合理的第二类是无效占位比如某些系统的空字符串、NULL这种字符串其实是脏数据第三类是条件缺失比如退货记录里没有发货时间这就不应该用全量填充。分类判断之后才能决定策略。# 先看缺失值和空字符串情况 print(df.isnull().sum()) # 按指定列删除缺失比如订单号必须有 df df.dropna(subset[order_id]) # 连续型字段用前向填充业务上通常代表“沿用上一个状态” df[status_seq] df[status_seq].ffill()dropna(subset[...])比全表dropna()要安全因为你只会删掉关键列缺失的行不会误伤其他列。ffill()前向填充在处理时间序列时很常用比如按日记录库存某天漏采了用前一天的值补上比用均值填充更合理。如果缺失值比较多还可以用thresh参数控制保留条件比如dropna(thresh5)表示一行里至少要有5个非空值才保留这在处理稀疏矩阵时非常有用。3.2 重复数据判断要看业务主键而不是看整行df.duplicated()默认判断整行完全重复。但在真实业务里“重复”往往指的是某个业务主键重复比如同一天同一个用户下了两次单这是合法记录而同一个订单号出现两次基本就是重复。所以正确姿势是先用业务字段判断唯一性df[order_id].is_unique # 简单看是否唯一 dup_rows df[df.duplicated(subset[order_id], keepFalse)] print(dup_rows)keepFalse会把所有重复行都标记出来而不是只标记后出现的行。这里我有一个惨痛教训之前处理会员积分数据习惯性地用了drop_duplicates()不带subset结果把两个不同门店但积分相同的合法记录删掉了。后来改成drop_duplicates(subset[member_id, store_id], keeplast)才符合业务上“同一会员同一门店只留一条有效记录”的规则。再有就是近似重复问题。比如姓名里多了个空格、电话号码有的带区号有的不带单纯用drop_duplicates是不行的。这种需要你先把文本规范化比如去掉空格、统一格式然后再去重。千万不要在还没规范前就去重否则真正的重复数据还是会漏网。3.3 astype与to_datetime的隐性错误类型转换看起来简单实际是清洗环节最容易静默出错的地方。第一个坑是astype遇到缺失值。比如一列数值里有NaN你直接df[price].astype(int)Pandas会报ValueError因为int类型不支持缺失值。解决办法是换成可空整型df[price] df[price].astype(Int64)注意这里是大写开头的Int64和常规的int64不同。可空整型允许NaN存在同时保持整数运算能力。如果你项目中会遇到缺失值建议一开始就养成用可空类型的习惯。第二个坑是pd.to_datetime的格式推断。同一个日期列里如果混着2024-01-05、2024/01/05、05/01/2024Pandas默认推断可能把某些行解析成不同的月日关系。最稳的办法是明确格式df[order_time] pd.to_datetime(df[order_time], format%Y-%m-%d %H:%M:%S, errorscoerce)errorscoerce会把解析不了的值变成NaT这样你事后可以用df[order_time].isna()找出格式异常的行。不要轻易省略format参数虽然有infer_datetime_format这类选项但那只是“推断”不是“保证”数据一杂就容易出问题。第三个坑是把字符串数字转成数值。CSV里有些金额字段是1,234.56带千分位逗号直接astype(float)会报错。需要先清理df[amount] ( df[amount] .astype(str) .str.replace(,, , regexFalse) .str.replace(元, , regexFalse) ) df[amount] pd.to_numeric(df[amount], errorscoerce)pd.to_numeric配合errorscoerce和上面的to_datetime是同一个思路先转转不动就给缺失值然后通过缺失值定位脏数据。这个方法比直接抛异常要友好得多因为你可以一口气把数据读完再集中看哪些行有问题。4. 分组聚合与多表合并复杂统计在Pandas中的正确拆解数据清洗干净后才真正进入统计逻辑。我经常看到有人用for循环遍历groupby的结果然后手动算数这种方式不是不行而是既慢又容易出错。Pandas已经提供了一整套分组聚合算子关键是学会组合它们。4.1 groupbyagg可以一次算出多个统计量比如维度是门店、指标是销售额要给出门店的总销售、订单量、平均金额、最大订单金额result ( df.groupby(store_id, as_indexFalse) .agg( total_amount(amount, sum), order_cnt(order_id, nunique), avg_amount(amount, mean), max_amount(amount, max), ) )这里用了命名聚合agg接收一个字典每个键对应一个新列名每个值是一个(列名, 聚合函数)元组。相比先groupby再分别sum、count、mean然后merge回去这种写法只要一次分组操作性能更好代码也更清晰。关于as_index简单讲如果设置as_indexFalse分组列会保留为普通列如果默认as_indexTrue分组列会成为索引。我推荐聚合之后尽量reset_index()或者直接用as_indexFalse毕竟把分组列当索引后续和其他表合并时会多一层索引转换的麻烦。还有一类需求是分组后取满足条件的行而不是聚合值。这种情况适合用groupby加filter# 只保留订单数超过100笔的门店 active_stores df.groupby(store_id).filter(lambda g: g[order_id].nunique() 100)groupby.filter是按组做筛选它返回的是原DataFrame的子集而不是聚合结果。这在需要保留明细、又希望按组条件过滤时非常好用。4.2 merge时on参数和how参数的选择逻辑多表合并在Pandas里是另一个高频环节。最常见的场景是把订单表和门店表按store_id合并起来补出门店名称、区域等维度字段。merged orders.merge(stores, onstore_id, howleft)这里的howleft很好理解以订单表为主订单表里每一行都能保留门店表里有匹配的则带出来没有匹配的门店字段就是NaN。如果换成howinner订单表里门店ID对不上门店表的行会被删掉这往往不是你想要的。所以在合并前先想清楚“以哪张表为主”以及“匹配不到时是否保留”再决定left还是inner还是right。merge还有一个经常出问题的点关联键是否唯一。如果右表里store_id有重复合并后左表的一个订单会对应右表多行行数会膨胀。这个膨胀很容易被忽略等你发现统计结果翻倍时已经晚了。我的习惯是在merge之前先检查键的唯一性print(stores[store_id].is_unique)如果不唯一要先搞清楚是不是业务上本就该一对多比如订单表关联订单明细表一个订单多条明细这样膨胀是正常的如果是一对一关系那就要去查数据源为什么多了行。merge的validate参数也可以用来强制校验数量关系orders.merge(stores, onstore_id, howleft, validatemany_to_one)如果实际结果不是many_to_one关系Pandas会直接报错。这个参数很多人没用过但我认为在搭建数据处理流程时应该多用它能把“数据质量假设”直接写进代码后续数据源变更时能第一时间发现。4.3 concat与merge到底怎么分工有些开发者搞不清concat和merge的区别。简单说merge是横向拼接根据字段匹配来合并concat默认是纵向堆叠把多张表一条条接在一起相当于“上下拼接”。横向加列的需求用concat(axis1)也能实现但如果没有关联键只是按行位置硬拼很容易因为两张表行序不一致而出错。所以横向拼接时我仍然推荐用merge。纵向拼接则一般是多个月度文件合并成一张大表monthly pd.concat([df_jan, df_feb, df_mar], ignore_indexTrue)ignore_indexTrue很关键。如果不设置合并后的行索引会保留原来的索引比如1月数据索引0-1002月数据又0-200合并后出现重复索引后续定位行时非常容易出问题。设置之后会重新生成0到N-1的连续索引省去很多麻烦。我用一个表格整理这两种操作的区别方便新手快速选择操作方向关联方式典型场景merge横向加列按字段匹配订单表关联门店表concat(axis0)纵向加行按列名对齐多月数据合并concat(axis1)横向加列按行位置对齐列方向补充计算字段慎用需要说明的是concat(axis0)虽然按列名对齐但如果两张表的列顺序不同它不会报错只会按列名重新排列。所以合并前最好先确认两边列名一致否则数据虽然“拼上了”但顺序可能乱套。5. 性能优化让几百万行数据跑出体感速度数据量一旦到几百万行性能问题就会变得特别具体。很多时候你写出的代码功能没问题就是跑起来很慢这时候值得花时间看看瓶颈在哪儿。5.1 向量化操作为主apply降级为备选Pandas性能优化最重要的一条原则能用向量化操作就不要用循环。所谓向量化是把“对每一行执行某个函数”变成“对整个列执行某个Pandas/SQL风格的运算”底层由C语言实现速度完全不是一个量级。比如要给订单金额分档新手可能写def amount_level(amount): if amount 100: return low elif amount 1000: return mid return high df[amount_level] df[amount].apply(amount_level)这个写法能用但在几百万行的数据上会慢得明显。更快的写法是用pd.cutdf[amount_level] pd.cut( df[amount], bins[-1, 100, 1000, float(inf)], labels[low, mid, high], )pd.cut本质上是按区间切分它是一次性作用于整列速度比逐行apply快很多倍。再比如条件赋值用np.where比apply(lambda x: ...)快得多import numpy as np df[is_big] np.where(df[amount] 1000, 1, 0)如果你发现自己用apply写逐行逻辑先停一下想想这个逻辑能不能用np.where、pd.cut、groupby.transform或者字符串访问器实现。能实现的话性能提升是非常明显的。5.2 索引、loc和iloc的正确姿势Pandas里有两个定位方式loc按标签iloc按位置。新手经常把两者混用或者用链式赋值触发SettingWithCopyWarning这些在性能上也是隐患。我的建议是优先用布尔索引定位别用循环一行一行改。筛选时尽量一次到位# 推荐做法一次性过滤 big_orders df[df[amount] 1000] # 需要修改列时用loc按条件定位 df.loc[df[amount] 1000, is_big] 1上面第二行其实也能用但要注意df.loc[条件, 列名] 值这个语法它同时做了两件事先按条件选出目标行再把目标列赋值。如果你写成df[is_big][df[amount] 1000] 1就会触发链式赋值Pandas会给出SettingWithCopyWarning而且这个赋值有时候确实不会写进原表属于最深的一种坑。所以只要你需要改列一律用df.loc[条件, 列名] 新值不要先选再改。还有一个与性能相关的小习惯如果筛选条件涉及多列尽量把计算量小、选择性强的条件放在前面减少后续计算的行数。这个优化非常微小但整体习惯是好的筛选条件尽量具体一次过滤到位不要筛完再筛。5.3 内存压缩与分块读取几百万行的数据读进内存后如果dtype不理想内存占用会非常夸张。比如状态字段如果读成object类型每行都存字符串引用占用会比category类型大很多。低基数的枚举列建议在读取时转成category类型df[status] df[status].astype(category)category类型在底层只存整数编码加上类别列表处理几十种固定状态值时内存能减少不少。对数值列只要能明确取值范围的也可以手动指定更小的dtype比如只要整数就用int32不要默认的int64。当单个文件实在太大时可以用分块读取chunks pd.read_csv( huge_data.csv, chunksize100000, usecols[order_id, amount, order_time], ) part_list [] for chunk in chunks: part chunk[chunk[amount] 0].copy() part_list.append(part) result pd.concat(part_list, ignore_indexTrue)chunksize会把文件分成多个DataFrame逐块处理配合usecols只读需要用的列内存能做到可控。全部算完后用concat汇总。这个方法特别适合在内存有限的笔记本上初探大文件。6. 一个真实数据处理过程复盘从拿到原始表到交付统计结果理论内容讲了不少我再用一个案例把流程串起来。这个案例脱胎于我处理过的销售明细数据业务背景已经抽象过但流程是真实的。6.1 需求与原始数据的样子某业务团队给我一张销售明细表目标是按“区域-门店-日期”维度输出销售额、订单量、客单价三个指标。原始表大概是这样的字段说明门店名称文本含空格和“旗舰店”后缀区域文本部分为空下单时间字符串格式不一支付金额金额带“元”和千分位逗号订单编号文本偶有重复因为文件是Excel导出的CSV几乎同时踩中了前面说的好几个坑BOM、空字符串、日期格式混用、金额文本化。如果我在读取阶段没有做任何dtype处理后面想要得到干净的统计结果至少要改三遍代码。6.2 处理链路与代码骨架我按下面的链路处理import pandas as pd import numpy as np # 1. 读取时处理BOM、指定ID类型、解析日期 df pd.read_csv( sales_raw.csv, encodingutf-8-sig, dtype{门店名称: str, 订单编号: str}, ) # 2. 列名规范化 df.columns df.columns.str.strip().str.replace( , _) # 3. 金额清洗 df[支付金额] ( df[支付金额] .astype(str) .str.replace(,, , regexFalse) .str.replace(元, , regexFalse) ) df[支付金额] pd.to_numeric(df[支付金额], errorscoerce) # 4. 日期统一 df[下单时间] pd.to_datetime(df[下单时间], errorscoerce) # 5. 删除关键字段缺失行 df df.dropna(subset[订单编号, 门店名称]) # 6. 按业务主键去重 df df.drop_duplicates(subset[订单编号], keepfirst) # 7. 派生日期维度 df[下单日期] df[下单时间].dt.date # 8. 分组聚合 result ( df.groupby([区域, 门店名称, 下单日期], as_indexFalse) .agg( 销售额(支付金额, sum), 订单量(订单编号, nunique), ) ) result[客单价] result[销售额] / result[订单量]这个链路每一步都不复杂但顺序有讲究先清洗再派生先派生再聚合。如果把金额清洗放在聚合之后会发现平均数被“元”这个字符串拉垮如果去重放在金额清洗之前则可能把同一订单号两条记录中金额不同的那一条保留下来导致统计结果不准。6.3 过程中真正让我返修的三个问题第一个问题是区域字段的空字符串。dropna(subset[门店名称])删不掉区域列的空字符串因为空字符串不是NaN。我当时做按区域的聚合结果发现有一个“区域”为空的行被汇总到NaN组里而且在输出表里很容易被忽略。后来我在清洗步骤里加了处理df[区域] df[区域].replace(, 未知) df[区域] df[区域].fillna(未知)第二个问题是订单量统计口径。如果直接用count()统计订单量会出现同一订单编号对应多条明细时订单量被重复计算。最后我选择用nunique()统计唯一订单数并在需求沟通时把“订单量唯一订单号数量”这个口径写进了交付说明。统计口径这种东西代码里一个小函数的选择到最后可能就是业务方争论不休的根源。第三个问题是客单价的计算位置。有人习惯在聚合SQL里直接sum(amount)/count(distinct order_id)但如果中间有金额为NaN的记录sum会忽略NaN而count却不计入NaN所以在清洗阶段把金额为NaN的脏行剔除掉比靠聚合函数猜测数据清洁度要安全得多。我最后在分组前加了df df.dropna(subset[支付金额])这一步让客单价的稳定性提高不少也避免交付后业务方拿明细核对时出现“同一张订单金额对不上”的质疑。这套链路后来被我整理成一个内部模板每次接手新数据源先跑探查脚本再按模板逐步清洗最后输出统计表。Pandas写数据处理的优势也正在这里每一步都有清晰的接口但不代表你可以跳过思考和校验。数据开发这种活代码只是最后呈现出来的载体真正的功夫都在分析和校验上。

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

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

免费获取报价 →
↑