Pandas实战:从脏CSV到干净数据的完整清洗指南

Pandas实战:从脏CSV到干净数据的完整清洗指南 干数据分析这行早晚会撞上一句话真实数据没这么干净。你在教程里看到的data.csv往往是精心准备好、格式统一、没有任何缺失的样例但现实里从业务系统导出的CSV列名带着空格、日期格式五花八门、重复行一堆、还有用逗号当小数点存进来的金额字段。第一次用Pandas读入这种文件你大概率会对着报错发懵。这篇博文就是围绕这个场景写的——用Pandas把一份典型的脏CSV一步步处理成能用于分析的数据集。内容包括read_csv读入时的参数细节、列筛选与条件筛选的常用写法、排序的注意事项、去重时那些容易踩坑的隐藏问题。内容面向刚接触Pandas的初学者也适合写过一段时间但没系统整理过清洗流程的读者。看完之后你可以直接照着这套思路处理手头的数据文件。1. 真实数据到底脏在哪先搞清楚我们要对付什么1.1 一份典型脏CSV文件的面貌我之前从一套订单系统里导出过一份CSV打开前以为直接就能跑分析结果第一眼就发现问题。文件长这样订单编号,用户ID,下单时间,金额,城市,状态 1001,U001,2024/1/3 14:22,99.9,北京,已完成 1002,U002,2024-01-05 09:11,199,上海, 已完成 1003,U003,20240107,59.9,广州,待支付 1001,U001,2024/1/3 14:22,99.90,北京,已完成 1004,U004,2024-01-09,299.00,深圳,已取消 1005,U005,2024-01-10 18:30,129.9,杭州,已完成 1006,U006,2024-01-12,49.9,杭州,已完成 1007,U007,2024-01-13,79.9,南京, 1008,U008,2024-01-15,89.9,南京,已完成 1009,U009,2024-01-16,129.9,杭州,已完成注意看几个细节第一列列名是订单编号没问题但第二、三列之间用中文逗号分隔不实际上这个文件里有些行用英文逗号有些行因为用户输入习惯问题混入了全角逗号。第三行日期格式是20240107第一行是2024/1/3第二行是标准2024-01-05日期格式压根不统一。金额有的带一位小数有的不带。状态列里有前导空格 已完成。还有一行重复数据第4行和第1行完全一样。最后一行城市是南京但前面也有一行南京城市列没太大问题但数据本身已经够乱了。这就是真实数据最常见的样子格式不统一、有重复、有缺失、有不可见空格。用Pandas读进来之后如果直接做统计结果一定是不对的。所以清洗的第一步是看清问题第二步才是动手处理。1.2 为什么清洗环节省不得很多人都觉得清洗是浪费时间不如赶紧跑模型。但数据分析里有一句老话垃圾进垃圾出。数据本身有问题后面计算出来的均值、总量、占比全都不可信。拿上面的数据举例。如果不做去重订单总量会多算一单如果不去空格已完成和 已完成会被当成两个不同的状态如果日期格式不统一按月份做聚合时2024/1/3和2024-01-03很可能被解析成不同的值或者直接解析失败。这些都不是什么高深的技术问题但每一个都会让最终结论产生偏差。清洗的目的不是把数据变成完美的样子而是把数据整理到能够被正确解读的程度。这个过程通常占据整个数据分析项目60%以上的时间是个不折不扣的体力活。Pandas的价值在于它把这部分体力活变成了几十行可复用的代码。1.3 为什么选择Pandas而不是Excel或者SQL很多人会问为什么不用ExcelExcel当然能做筛选、排序、去重但问题是数据量一大几十万行以上Excel就开始卡而且Excel里的操作很难留下记录做了一次之后换一份数据还得重新点一遍。SQL也能做筛选去重但SQL的前提是数据在数据库里而且处理复杂的字符串清洗比如去空格、统一日期格式比较痛苦需要写一大堆函数嵌套。Pandas是介于两者之间的选择既能像Excel一样可视化地操作又能像代码一样保留完整的处理步骤。尤其是read_csv配合DataFrame的各种方法可以让一份脏数据从读入到清洗完成全部脚本化。下次拿到同样格式的文件直接跑脚本几秒钟出结果。2. 读入CSV的正确姿势别让数据在门口就丢了2.1 read_csv参数详解常用参数逐一拆解pd.read_csv()可能是Pandas里最常用的函数之一但很多人只用了默认参数。默认参数对付干净数据没问题一旦遇到真实数据就开始报错或乱码。我在实际使用中这几个参数几乎每次都要调整。import pandas as pd df pd.read_csv( orders.csv, encodingutf-8-sig, sep,, dtype{用户ID: str, 金额: float}, parse_dates[下单时间], na_values[, NULL, nan], )先看encoding参数。Python读取文本文件时默认用系统编码Windows下可能是gbkmacOS/Linux下是utf-8。如果CSV文件是UTF-8编码而你在Windows上没指定编码大概率会报UnicodeDecodeError。我这里用utf-8-sig是因为很多从Excel另存的CSV会在文件开头带一个BOM字节顺序标记utf-8-sig会自动把这个BOM去掉防止第一列列名变成\ufeff订单编号这种奇怪的东西。然后是dtype参数。Pandas默认会做类型推断但推断不一定准。比如用户ID如果是纯数字Pandas会把它读成int64但用户ID本质上是一个标识符不是用来做加减乘除的读成字符串更合理。金额如果里面混了空字符串默认推断可能直接变成object类型还不如在读取时就用dtype指定。parse_dates可以指定哪些列要解析成日期类型。不指定的话日期列默认是字符串之后做时间筛选、按月聚合都不方便。这里要注意Pandas解析日期也有失败的可能——如果某种奇怪格式没有被识别解析出来的会是NaT。后面我还会讲到日期格式统一的问题。na_values参数用于指定哪些值应该被当作缺失值。默认情况下Pandas只把NaN、None当作缺失值但很多系统导出时喜欢用NULL、#N/A、空字符串等来表示缺失。把这些值加进na_values在读入阶段就完成了缺失值识别后面不用再做一遍字符串替换。还有sep参数也就是分隔符。默认是英文逗号,但有些CSV文件是从某个系统导出的分隔符是制表符\t或者是分号;。如果不确定可以先打开文件看一眼或者用sepNone让Pandas自动推断。不过自动推断偶尔会翻车稳妥起见还是自己指定。2.2 读入后的第一件事别急着处理数据先做数据体检数据读入之后我建议先做一套数据体检确认数据真的按预期读进来了。这套检查通常包含4行代码print(df.shape) # 查看多少行多少列 print(df.dtypes) # 查看每列的数据类型 print(df.head(10)) # 查看前10行数据 print(df.isnull().sum()) # 查看每列缺失值数量shape看的是数据规模如果你预期10000行结果只有9999行那就要想是不是有表头错认、空行被跳过的问题。dtypes看类型有没有应该读成数字却变成object的列。head(10)直接看前几行长什么样和原始CSV对比一下快速发现列名错位、编码乱码这类问题。isnull().sum()统计缺失值确定后面要重点处理的列。这几行代码不复杂但非常重要。很多时候清洗失败不是因为清洗步骤写错了而是因为一开始数据读入的方式就有问题——比如列名带BOM、某列应该读成数值但被读成了字符串。如果不做体检直接往下处理后面的每一步都建立在错误的基础上白费功夫。2.3 编码问题实战手机打开正常电脑打开乱码是怎么回事网上有个热搜问题说同一个CSV文件手机打开正常电脑打开乱码。这个现象我也碰到过。原因很简单手机的文本查看器会自动检测编码而Excel在Windows下默认用ANSI也就是gbk去打开所有文件。如果文件本身是UTF-8编码Excel就会把它按GBK解码中文自然乱码。解决办法有两种。第一种是在生成CSV的时候就用utf-8-sig编码保存这样Excel打开时会优先识别BOM标记按UTF-8解析。第二种是打开时手动指定编码——在Excel里通过数据-从文本/CSV导入选择文件编码为UTF-8。对于数据清洗流程来说我建议统一用utf-8-sig作为标准无论是读入还是导出都明确定义编码这样就不会出现这台电脑能读那台电脑乱码的尴尬。3. 筛选从表格里把有用的行和列挑出来3.1 按行筛选布尔索引、query、isin三种常用方法筛选是Pandas里最频繁的操作之一。按行筛选最基础的是布尔索引——把一个布尔序列放进方括号里Pandas会保留为True的行。# 筛选出金额大于100的订单 df[df[金额] 100] # 筛选出城市为北京或上海的订单 df[df[城市].isin([北京, 上海])]这里有个新手经常踩的坑多个条件同时满足时要用而不是and要用|而不是or。原因是Pandas的和|是对逐元素进行逻辑运算而Python的and和or只处理单个布尔值。另外每个条件都要加括号因为的优先级比低不加括号就会报ValueError: The truth value of a Series is ambiguous。另一种方法是query()写起来更接近SQL风格可读性更好df.query(金额 100 and 城市 in [北京, 上海])query会把字符串当成表达式去解析列名直接写在里面不需要用df[列名]的写法。对于复杂的筛选逻辑query更简洁也不容易出现括号遗漏的问题。不过我个人的经验是如果筛选条件里涉及列名本身包含空格或特殊字符的情况用query需要把列名用反引号包起来比较麻烦这时直接用布尔索引更稳妥。3.2 按列筛选选择需要的字段减少数据体积按列筛选用的是df[[列名1, 列名2]]注意要用双层方括号返回的才是一个DataFrame用单层方括号返回的是Series。如果你只需要某几列建议在做任何运算之前先把无关的列去掉。这样既能让数据在内存里更小也能避免后续处理时不小心用到这些无关字段。还有一个高阶一点的写法是df.filter()df.filter(regex金额|用户) # 筛选列名包含金额或用户的列 df.filter(items[订单编号, 状态]) # 精确指定列名filter适合列名有一定规律的情况比如有很多列都叫金额_上月、金额_本月用regex一下子全选出来。不过filter的regex匹配的是列名字符串不是列的值别搞混了。3.3 筛选时容易忽视的细节先清洗再筛选顺序不能反筛选逻辑本身不难但有一个顺序问题容易影响结果如果列里有缺失值或空格脏数据筛选条件可能会把本该保留的行漏掉。举个例子假设金额这一列里有几条记录是字符串 99.9带一个前导空格。这个时候如果直接用df[df[金额] 100]Pandas会报错因为字符串不能和数字比大小。又比如状态列里有已完成和 已完成如果用df[df[状态] 已完成]筛选带空格的行就会被漏掉统计结果偏少。所以标准的清洗流程应该是先处理列的格式问题剥离空格、类型转换再执行筛选。顺序颠倒的话筛选条件可能因为格式问题而误伤数据后面发现结果不对还得回来重新处理。4. 排序让数据在展示和分析时更有条理4.1 sort_values的核心参数与用法排序是整理数据最直观的手段。sort_values()的基本用法是传入一个或多个列名指定升序或降序df_sorted df.sort_values(by金额, ascendingFalse) # 按金额降序 df_sorted2 df.sort_values(by[城市, 金额], ascending[True, False]) # 先按城市再按金额先按城市升序同一城市内按金额降序这种多列排序在分析场景里很常见。比如想看每个城市消费最高的订单按城市金额降序排序之后每个城市的第一行就是最大金额的那单。需要注意几个参数ascending可以传一个布尔值也可以传一个列表和多列排序一一对应。如果传列表长度必须和by的列数一致否则报错。na_position缺失值放在最前面还是最后面默认是last放到最后。如果你希望缺失值出现在最前面比如排查缺失数据就设置成first。kind排序算法默认是quicksort快速排序但快速排序不是稳定排序。如果对多列排序并且希望保持原始行的相对顺序可以改成mergesort归并排序更稳定但速度稍慢。4.2 排序后索引会乱掉用reset_index重建连续索引排序之后有一个容易忽略的细节DataFrame的索引还是原来的索引不会跟着行一起重排。也就是说排序后的第一行可能是原数据的第100行此时它的索引还是99。如果接下来做批量操作或绘图这个乱序索引会带来麻烦。解决办法是调用reset_index(dropTrue)df_sorted df.sort_values(by金额, ascendingFalse).reset_index(dropTrue)dropTrue表示不把原索引保存为新的一列。如果你想保留原来的索引作为备用信息可以不加drop参数原索引会变成index列。大多数情况下我都是直接dropTrue让新数据的索引变成干净的0,1,2,...序列。4.3 排序的一个实战场景取每组的TopN排序组合操作能解决很多实际问题。比如我想看每个城市金额最高的两个订单可以用groupby加headdf_top2 df.sort_values(金额, ascendingFalse).groupby(城市).head(2)这段代码的逻辑是先按金额全局降序排序然后按城市分组每个组取前2行——因为已经排好序了取到的就是每个城市金额最高的2条。这样做比groupby之后再用apply逐组排序效率高很多代码也更简洁。5. 去重听起来简单实际全是坑5.1 drop_duplicates的三组关键参数去重用drop_duplicates()核心参数有三个subset、keep、ignore_index。# 完全重复的行只保留第一条 df.drop_duplicates() # 按订单编号去重保留最后一条 df.drop_duplicates(subset[订单编号], keeplast) # 按订单编号和用户ID去重并重置索引 df.drop_duplicates(subset[订单编号, 用户ID], keepfirst, ignore_indexTrue)subset指定判断重复的列。如果不传默认是所有列都相同才算重复。这个参数非常关键很多时候两行数据不是完全一样只是某个主键字段比如订单编号相同那么就应该用subset[订单编号]去重而不是对所有列比较。keep有三个取值first保留第一条、last保留最后一条、False删除所有重复行。保留第一条还是最后一条取决于业务含义。比如订单数据里后导入的记录可能状态比之前的记录新这时保留last更合理。ignore_index设置为True时去重后索引会自动重置为0到n-1省得再调reset_index(dropTrue)。5.2 去重不生效看着一样实际不一样这个坑我踩过好多次也帮别人排查过很多次数据明明有重复行但drop_duplicates()就是不去重。原因通常不是代码写错了而是看起来一样的数据在底层并不完全相等。常见情况有三种。第一种是字符串里的不可见字符。比如从文本文件里复制过来的字符串前后可能带了\r、\n、\t或者空格。两个字符串肉眼看着一样但如果一个带尾随空格一个不带Pandas会认为它们不同。第二种是数据类型不一致。一个字段在A行里是字符串100在B行里是整数100虽然显示出来一样但是100 100返回False自然不会被判定为重复。这种情况通常发生在读入CSV时某些行没有引号、某些行有引号导致类型推断不一致。第三种是列表或字典这类不可哈希类型。当DataFrame某列的元素是list或dict时drop_duplicates()会直接报错因为这些类型不可哈希。如果遇到这种数据一般得分列处理或者先用astype(str)转成字符串再去重。排查方法很简单把看起来重复的那两行分别打印出来用type()看看类型用repr()看看有没有隐藏字符。print(repr(df.loc[0, 状态])) print(repr(df.loc[1, 状态]))repr会显示字符串的完整内容包括空格和换行问题一目了然。5.3 和SQL去重、数组去重的区别如果你用过SQL可能会觉得drop_duplicates和SELECT DISTINCT是一回事。其实不然。SELECT DISTINCT是对结果集中所有列去重而Pandas的drop_duplicates通过subset参数可以只针对某列去重保留这一列之外的其他字段。这个能力更接近ROW_NUMBER() OVER (PARTITION BY ...)窗口函数但写法简洁得多。和Python原生数组去重比如list(set(arr))相比Pandas去重是按整行或按指定列去重不是按单个元素去重。另外Pandas的drop_duplicates底层是用哈希表实现的对于千万级数据量也有不错的性能不会像两层for循环那样慢到不可接受。5.4 去重之后别急着收工同步检查缺失值和格式去重只是清洗流程里的一环千万别以为去完重数据就干净了。我通常的做法是去重之后再做一轮数据体检确认这次操作没有引入新的问题。比如按订单编号去重之后总行数会变少这时可以用df.shape确认减少的数量是否符合预期。如果预计去掉100行结果去掉了500行那就要小心是不是数据本身有大量不合格的记录。另外还要验证其他列的数据没有因为去重而丢失关键信息——比如同一订单编号有两行一行状态是已完成一行是已取消直接按订单编号去重会丢掉其中一条状态信息。遇到这种情况正确的做法是先确定业务含义保留哪条再做去重否则结果会有偏差。6. 完整实操把一份脏CSV清洗成可用数据6.1 准备一份模拟脏数据为了演示完整流程我用代码生成一份模拟数据。注意这里故意加入了几种典型的脏数据问题列名带BOM、重复行、日期格式不统一、字符串空格、缺失值。import pandas as pd import numpy as np # 构造一份带脏特征的数据并保存为CSV data { 订单编号: [1001, 1002, 1003, 1001, 1004, 1005, 1006], 用户ID: [U001, U002, U003, U001, U004, U005, U006], 下单时间: [2024/1/3 14:22, 2024-01-05 09:11, 20240107, 2024/1/3 14:22, 2024-01-09, 2024-01-10 18:30, 2024-01-12], 金额: [99.9, 199, 59.9, 99.90, 299.00, 129.9, None], 城市: [北京, 上海, 广州, 北京, 深圳, 杭州, 杭州 ], 状态: [已完成, 已完成, 待支付, 已完成, 已取消, 已完成, 已完成], } df_raw pd.DataFrame(data) df_raw.to_csv(dirty_orders.csv, indexFalse, encodingutf-8-sig)6.2 从读入到清洗逐步执行并观察每一步的变化第一步读入CSV并做数据体检。df pd.read_csv(dirty_orders.csv, encodingutf-8-sig, dtype{用户ID: str}) print(df.shape) # (7, 6) print(df.dtypes) print(df.head())这时候会发现金额列是object而不是float64因为里面混了NaN和字符串。如果直接用df[金额].sum()类型错误会找上门。第二步处理字符串空格。# 对城市和状态两列剥离首尾空格 df[城市] df[城市].str.strip() df[状态] df[状态].str.strip()这个操作也可以用df.columns df.columns.str.strip()来做列名的空格清理。很多CSV的列名带着空格比如订单编号 不清理的话后面写代码时列名对不上。第三步统一日期格式。df[下单时间] pd.to_datetime(df[下单时间], formatmixed, dayfirstFalse)Pandas 2.0以上支持formatmixed来自动推断多种日期格式。如果是旧版本可能需要先对格式做归一化比较麻烦。统一后可以验证一下print(df[下单时间].dtype) # datetime64[ns]第四步处理金额列的类型转换和缺失值填充。df[金额] pd.to_numeric(df[金额], errorscoerce) df[金额] df[金额].fillna(0)pd.to_numeric可以把字符串列转成数值无法转换的值变成NaN再用fillna(0)填充。这里fillna(0)还是fillna(df[金额].median())取决于业务场景如果金额的缺失值占比很低用0填充或者删除都是常见处理方式。第五步筛选出状态为已完成的订单。df_done df[df[状态] 已完成]注意因为前面已经做过strip所以这里不会出现已完成和 已完成分家的情况。第六步按金额降序排序并重建索引。df_sorted df_done.sort_values(金额, ascendingFalse).reset_index(dropTrue) print(df_sorted)第七步按订单编号去重。df_result df_sorted.drop_duplicates(subset[订单编号], keepfirst, ignore_indexTrue)最后把清洗后的结果导出为新的CSV。df_result.to_csv(clean_orders.csv, indexFalse, encodingutf-8-sig)整个流程从读入到输出只有十几行代码但每一步都有明确的目的。我自己在实际项目里通常会把这一套逻辑写成一个函数输入文件路径输出清洗后的DataFrame这样下次再拿到同类文件直接调用省时省力。6.3 每一步的检验方法边做边看别到最后才发现问题清洗流程跑完之后不能直接就开始分析。我习惯做三件事第一用shape对比清洗前后的行数变化确保每一步的结果符合预期第二用info()或dtypes确认关键列的类型都是预期的第三随机抽几行肉眼看一下数据内容和原始数据对比有没有异常。这一步很多人都会跳过但它恰恰是最能防止清洗出事故的手段。尤其当数据量大的时候肉眼不可能逐行看但随机抽样检查加上类型和行数校验已经能覆盖大多数常见问题。7. 常见报错与排查心得7.1 AttributeError: module pandas has no attribute core这个报错在热词里出现过我也遇到过。典型触发场景是代码里写了pandas.core.series.Series这样的路径然后在某些Pandas版本上直接报错。原因是在新版本里pandas.core这个模块路径不再作为公开API暴露。解决办法有两个一是改代码不要依赖pandas.core这种内部路径改用pd.Series或pd.DataFrame二是如果确实需要用内部功能可以尝试升级或降级Pandas版本但我不建议这么干——依赖内部API本质上是在编写脆弱的代码换个版本可能就崩了。7.2 ParserError: Error tokenizing data这个报错几乎每个用Pandas读CSV的人都会遇到。直接原因是某行的字段数量和其他行不一致。比如文件里某行数据里不小心多了个逗号或者某字段里包含英文逗号但没有被引号括起来。排查方法是用文本编辑器打开CSV文件找到报错位置附近的行看看分隔符是不是异常。另一个常见处理方式是给read_csv传enginepython它的容错性更好但速度会慢一些。真正稳妥的方案还是回到源头把CSV的生成规则规范起来字段中包含逗号的一律用双引号括起来。7.3 UnicodeDecodeError: utf-8 codec cant decode byte 0X这个问题在Windows环境尤其常见。如果你用默认编码打开一个UTF-8文件或者反过来都会报这个错误。我排查过几次通常不是代码写得有问题而是文件本身用了gbk编码。解决办法无非是换encodinggbk重试或者用chardet这类库去检测文件的真实编码。如果你的团队协作中经常交换CSV文件强烈建议统一使用utf-8-sig编码并且在文件名或说明文档里写清楚编码格式这样能省去很多沟通成本。7.4 SettingWithCopyWarning看起来像报错其实是警告这个警告在筛选后赋值时经常出现。比如df_done df[df[状态] 已完成] df_done[新列] 1Pandas会警告你df_done可能是df的一个视图view而不是副本copy修改它可能不会生效。解决方法是显式复制df_done df[df[状态] 已完成].copy()copy()会生成一个独立的DataFrame后续的修改不会再影响原始数据也不会触发警告。这是一个非常好的习惯——凡是做了一次筛选得到的子集如果后续要修改就加一个copy()避免很多诡异的问题。7.5 排查心态先确认数据再怀疑代码最后说一点心得。数据清洗的报错绝大多数时候不是Pandas的bug也不是语法写错而是数据本身不符合我们的假设。比如你以为金额列全是数字实际上混了未知、N/A你以为日期列全是标准格式实际上有2024/1/1和20240101并存。所以排查问题的时候先回到数据源头用head()、dtypes、isnull().sum()把数据的状况摸清楚再回头审视代码。这个顺序搞反了很容易陷入改代码-报错-再改代码的死循环。我个人实际操作中的经验是每次拿到一份新数据文件先写一个最小的探查脚本读入、体检、打印前几行花五分钟把数据底细摸清楚再动手写清洗逻辑。这个探查阶段花的时间往往比后面反复调代码省下来的时间少得多。数据清洗不是一锤子买卖它是分析流程里最需要耐心和细心的一环——但只要把流程理顺、步骤固定越用越顺。