多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Python数据清洗实战:用pandas搞定Excel脏数据

Python数据清洗实战:用pandas搞定Excel脏数据 这是学习日记的第六十七天。前面六十多天我把Python基础语法、函数、文件操作、列表推导式这些东西过了一遍也写了不少练习题但总有一种学了但没真用的飘忽感。今天终于下决心干一件实在的事把一份乱糟糟的Excel表格清洗成能直接做分析的数据表。这份表来自我手头一个模拟的订单数据项目里面什么奇怪的东西都有——重复行、日期格式三四种写法、金额列里混着千分位逗号和未知两个字。整个过程从上午折腾到傍晚最大的收获不是记住了几个函数而是明白了数据清洗四个字到底有多重要。这篇文章就当今天的复盘笔记把每一步做了什么、为什么这么做、踩了什么坑完整记录下来。如果你也在自学Python正处于基础都会但不知道做什么的阶段今天的经历应该能给你一个不错的参考方向。1. 第67天我决定先解决数据太脏这个现实问题1.1 一小时的Excel手操彻底劝退了我事情的起因很简单模拟项目X的接口导出了一份订单明细一共三千多行我原本想用Excel透视表做个销售汇总。结果打开文件第一眼就觉得不对劲——订单编号这一列有那么几行没有表头往下翻又看到同一个订单号出现了三次备注栏还不一样成交金额里既有1,200这种带逗号的又有- 50这种带负号的甚至还有两格直接写着未知。日期就更别提了2024/1/5、2024-01-05、2024.01.05三种格式混在一起Excel的筛选功能面对这种数据完全失灵。我试着在Excel里手动修先把重复行高亮出来再一行一行改日期格式。弄了一个小时眼睛都快花了才处理了两百行。那一刻我突然意识到如果将来面对的是几万行数据手工清洗这条路根本走不通。于是我把Excel关掉打开编辑器决定用Python把整个清洗过程写成脚本。1.2 今天的目标范围和不做清单为了防止自己陷入什么都想学、什么都没做完的怪圈我给自己定了三条清晰的目标第一把原始数据读进pandas保证中文不乱码第二清洗后生成一份新表行数准确、字段类型正确、日期统一、金额可以参与聚合计算第三把清洗逻辑写成函数方便下次换一个文件直接复用。同时我也列了一份今天不做清单不搞可视化、不做数据分析报告、不优化性能。理由很简单——先把基础链路打通其他都是后续的事。事实证明这个边界很管用因为数据清洗本身已经够折腾了如果再掺入画图之类的事今天肯定写不完。2. 工具选型为什么是pandas而不是openpyxl或手工在Excel里改2.1 三个候选工具的对比在动手之前我特意把pandas、openpyxl和Python内置的csv模块放在一起比了比。我之前学过openpyxl它擅长处理Excel的单元格样式、合并单元格、写公式但在按列批量转换类型、过滤重复行、填充缺失值这些分析场景里操作起来非常别扭——你得一个个单元格读再自己写循环判断。内置csv模块更基础既能读也能写但同样没有现成的去重、类型转换方法。pandas则完全是另一个思路它把整张表当成一个DataFrame对象对列做操作时一次作用于整列根本不需要显式写循环。比如去除重复行就是一行动手统一日期格式也是一行调用。我把三者简单整理成了对比表方便自己决策。能力维度pandasopenpyxlcsv 模块批量类型转换强支持整列操作弱需逐单元格处理无重复行检测与删除一行搞定支持按子集判断需手动遍历需手动遍历缺失值处理内置fillna / dropna需自定义逻辑需自定义逻辑日期解析能自动解析多种格式需自己逐格解析无写入Excel样式一般能写表头、列宽有限非常强不支持Excel学习曲线中等偏陡平缓平缓我看完这张表之后思路基本清楚了如果任务是给表格做体检、做整形pandas是效率最高的那个。如果任务是生成一份版式精美、带公式和样式的报表openpyxl更合适。今天的场景明显属于前者所以主工具就直接定为pandas。2.2 环境准备装库和确认版本我当前的Python版本是3.11pandas已经是2.x版本了。如果你还没有装pandas直接执行安装命令就行pip install pandas openpyxl这里有一点需要特别说明pandas读取xlsx文件时需要依赖openpyxl引擎所以即使你只需要读取Excel也建议把openpyxl一起装上。另外如果之前装过旧版本最好顺手升级一下因为pandas 1.x和2.x在部分方法的行为上有细微差异网上不少教程是旧版本的写法实际运行时可能会弹警告。python -c import pandas as pd; print(pd.__version__)我在装完库之后先输出版本号确认环境正常这一步虽然琐碎但能避免后面排查问题时搞不清楚是代码问题还是版本问题。3. 清洗过程拆解从乱码到干净数据的五个关键步骤3.1 第一步用正确的编码把CSV读进来我手头这个模拟项目的导出文件其实是CSV格式但它是用Excel另存为CSV生成的文件。这里有一个隐藏很深的坑Excel在Windows上另存的CSV默认使用ANSI编码也叫GBK而pandas默认用UTF-8读取。如果我不做任何处理直接执行pd.read_csv(订单明细.csv)读出来的中文大概率是乱码。我第一次踩这个坑的时候第一反应是pandas出bug了后来才明白是编码问题。解决的写法是显式指定编码import pandas as pd df pd.read_csv(订单明细.csv, encodinggbk)如果你不确定文件的编码可以用一种实用的探测方法用二进制模式先读一小段然后依次尝试utf-8、gbk、utf-8-sig三种编码解码哪种不报错就采用哪种。具体代码我放在后面踩坑实录小节里。这里只给结论今天这个文件用gbk读取是对的读进来后中文列名、中文备注都正常显示。3.2 第二步统一列名和数据类型读进来之后我先用df.head()和df.info()两张经典命令看一下结构。df.head()能看到前五行数据df.info()能显示每一列的非空数量、数据类型等信息。这一步很有必要因为原始文件里首列没有表头pandas会自动把第一列命名为Unnamed: 0不处理的话后面筛选会非常困惑。我决定给列做一次统一重命名顺手把列名改成年初中文的清晰版本df df.rename(columns{Unnamed: 0: 行号, 订单号: 订单编号, 订单编号: 订单编号_旧})等等这么做其实不太对——我仔细看了原始文件的表头发现订单号和订单编号两列都存在而且内容有交叉重复。这种情况下只改列名是不够的还要想清楚到底以哪一列为主。最终我选择以订单编号为唯一订单标识把订单号这一列直接保留并重命名为订单号_冗余后续做校验用不参与主键判断。关于数据类型df.info()显示成交金额这一列是object类型而不是float原因就是里面有逗号和未知字样。日期列同样是object。这两列都需要在后续步骤里做特殊处理我先标记出来等到第3.5步统一解决。3.3 第三步按订单维度去重去重是这次清洗里第一个需要业务判断的地方。如果简单地用df.drop_duplicates()pandas会把所有列都相同的行视为重复但今天这份数据里同一个订单编号出现了多次备注内容不同说明它在业务上可能被多次修改过。我真正需要的重复定义是同一个订单编号 同一个商品行号如果这两项都相同才算真正重复。实现方式是用subset参数指定参与判断的列keep参数决定保留哪一行。我选择保留最后一条记录因为模拟项目的导出逻辑里越靠后的记录越接近最终状态df_clean df.drop_duplicates(subset[订单编号, 商品行号], keeplast)去掉重复之前的行数是3271去重之后变成3002。这个数字对得上我的预期说明原始数据里确实有269行冗余。去重后我把旧索引重置了一下方便后面操作。df_clean df_clean.reset_index(dropTrue)这一步看似不起眼但没有reset_index的话经过筛选后的DataFrame索引会保留原来的杂乱数字后续用iloc或者按索引取数时很容易出错。3.4 第四步处理缺失值和无效数据接着看缺失值。df.isnull().sum()按列统计了缺失数量问题最大的是收货城市缺了102行支付方式缺了31行成交金额里有一行是未知。缺失值怎么处理取决于业务含义。pay方式缺失我的选择是填一个未知字符串因为即使支付方式不明确这条订单记录本身还是有效的不能删。收货城市缺失的情况比较复杂我去掉了这些行。原因是我后续可能会做区域维度的分析城市为空会导致整条记录没法参与区域统计而且102行相对于3002行来说是3.4%的比例删除后不影响整体分析。这里我提醒自己不要养成就地改数据的坏毛病——原始文件始终备份着清洗后生成的是新文件万一判断错误还能重新清洗。代码是df_clean df_clean.dropna(subset[收货城市]) df_clean[支付方式] df_clean[支付方式].fillna(未知)清洗到这里未知金额那一行还没有处理。我把它放到金额统一处理的环节一起解决因为未知本质是类型转换问题单独填充没有意义。3.5 第五步日期、金额的格式统一与业务标记原始数据里的日期有三种格式而且都是字符串。pandas的to_datetime函数能自动解析大部分常见写法但为了让解析更稳定、速度更快我建议显式传入format参数。今天的日期统一用format%Y-%m-%d处理df_clean[下单日期] pd.to_datetime(df_clean[下单日期], format%Y-%m-%d, errorscoerce)errorscoerce的意思是如果某个字符串转换失败就把该位置设为NaT表示时间缺失。我特意没有用默认的errorsraise因为测试数据里总有几行格式是乱的有了coerce之后可以用df_clean[df_clean[下单日期].isna()]快速定位这些问题行。金额处理分两步。第一步是清理把千分位逗号、人民币符号去掉并把未知替换为NaN第二步是类型转换使用pd.to_numeric转换失败的也会变成NaN。清清楚的写法如下def clean_money(value): if isinstance(value, str): value value.replace(,, ).replace(, ).replace(元, ) if value 未知: return float(nan) return value df_clean[成交金额] df_clean[成交金额].apply(clean_money) df_clean[成交金额] pd.to_numeric(df_clean[成交金额], errorscoerce)至于那些金额是负数的行我没有直接删除。因为负数在业务上可能是退款单或冲销单真正的处理方式应该是先加一个交易类型标记列把金额为负的行标记为退款金额为正的标记为成交。这样在做销售额汇总时可以用条件筛选排除退款单而不是把原始数据弄丢。df_clean[交易类型] df_clean[成交金额].apply(lambda x: 退款 if x 0 else 成交)这一步做完之后df.info()显示df_clean的成交金额是float64下单日期是datetime64整张表的数据类型终于符合可以直接做分析的标准了。我把清洗后的表写成了新文件df_clean.to_excel(订单明细_clean.xlsx, indexFalse, engineopenpyxl)4. 踩坑实录编码、类型、日期三大问题的完整排查链路4.1 乱码是UTF-8还是GBK我用一个方法探测出来前面说过直接读CSV时中文乱码是大概率事件。但乱码也分情况有时候是utf-8文件被当成了gbk有时候反过来。光靠肉眼猜不是办法我写了一个探测函数用三组常见编码依次尝试哪个成功就用哪个def detect_encoding(file_path): with open(file_path, rb) as f: raw f.read(2000) for enc in [utf-8, gbk, utf-8-sig]: try: raw.decode(enc) return enc except UnicodeDecodeError: continue return utf-8原理很简单每种编码都有自己的字节规律比如utf-8编码的中文通常以多字节形式存在用gbk去解码时大概率触发UnicodeDecodeError。通过逐一尝试就能锁定正确编码。今天这个文件返回的是gbk和我预判一致。有一点要提示CSV文件的编码和Excel文件的编码规则不完全相同。xlsx文件内部是XML天然使用UTF-8pandas读xlsx时一般不会出现中文乱码容易出问题的是CSV尤其是从老版本Excel另存为CSV出来的文件。所以遇到乱码时先问一句这个文件是CSV还是Excel4.2 1,200和未知to_numeric的暴力转换为什么不可行我刚处理金额时脑子一热直接写了pd.to_numeric(df_clean[成交金额], errorscoerce)结果转换后的金额列出现了很多NaN。细查发现像1,200这样的值会被整体视为无法转换的字符串从而变成NaN。原因很直接pandas默认不识别千分位逗号1,200中的逗号它认为这不是合法的数字写法。如果不想自己写清洗函数也可以用pandas内置的字符串替换方法比如df_clean[成交金额] df_clean[成交金额].astype(str).str.replace(,, , regexFalse) df_clean[成交金额] df_clean[成交金额].str.replace(, , regexFalse) df_clean[成交金额] pd.to_numeric(df_clean[成交金额], errorscoerce)作为踩坑总结我想强调一个原则类型转换失败时不要急着用errorscoerce掩盖错误而要先看看转换失败的到底是一些什么样的值。我就是在转换之后用df_clean[df_clean[成交金额].isna()]把问题值全部列出来才发现漏了逗号和未知两个字。如果你先看数据再动手很多坑都能提前避开。4.3 日期解析的Warning为什么同一列有三种写法日期列统一格式时pandas弹出了一条警告大意是某种格式无法自动解析已使用替代方式。我一开始没在意后来发现部分日期的数值被解析成了当前时间附近的近似值甚至年份都变了。这条警告提醒我自动解析看起来方便但面对混合格式时并不可靠。解决方案是先用pd.to_datetime(..., errorscoerce)做一次试探找出所有转换后为NaT的行把这些行的原始写法打印出来mask_nat df_clean[下单日期].isna() if mask_nat.any(): print(df_clean.loc[mask_nat, 下单日期].head(20))然后根据打印结果决定是否按格式拆分处理。今天的文件里除了三种常规日期还有两行写着2024年1月6日这就是自动解析失败的原因。我把它们单独提取出来用第二种format做转换后再填回去。思路是先按主流格式解析对解析失败的行再逐个格式补测。4.4 去重后行数反而变多我对重复的定义做了修正第一次做去重时我用subset[订单编号]结果去重后行数从3271变成了2987表面看起来没问题。但实际一核对发现同一个订单编号下面可能有两种不同的商品行按订单编号去重会把同一订单的多行明细误杀。这让我意识到重复的定义完全取决于业务语义。如果一份订单对应多条商品明细那么订单编号只能是组标识不能作为唯一键真正唯一的是订单编号商品行号这个组合。我改成这个组合去重后行数从2987变成了3002反而比原来多了15行。这15行就是之前误删的商品明细。这是一个很典型的业务理解问题不是代码问题。所以我特别建议在进行任何去重操作之前先想清楚什么字段组合在一起才能唯一标识一行。想不清楚就把两个可能性都跑一遍对比差异往往能发现数据背后的业务规律。5. 从一次性脚本到可复用工具函数化、参数化和容错5.1 函数拆分read_raw / clean_data / write_output清洗流程跑通之后我没有就此收工而是把整个逻辑拆成三个函数。这样做的好处很实际下次换一个数据文件我不需要从头改代码只要改文件路径、列名映射、去重规则三个参数就行。def read_raw(file_path): encoding detect_encoding(file_path) df pd.read_csv(file_path, encodingencoding) return df def clean_data(df, key_colsNone, date_colNone, date_format%Y-%m-%d): if key_cols is None: key_cols [订单编号, 商品行号] df df.drop_duplicates(subsetkey_cols, keeplast) df df.dropna(subset[收货城市]) if date_col: df[date_col] pd.to_datetime(df[date_col], formatdate_format, errorscoerce) # 其他清洗逻辑根据实际数据扩展 return df.reset_index(dropTrue) def write_output(df, out_path): df.to_excel(out_path, indexFalse, engineopenpyxl)函数化的同时我把路径都改成了Path对象管理这样在Windows和macOS之间切换时不容易出现斜杠方向的问题from pathlib import Path raw_path Path(data) / 订单明细.csv clean_path Path(output) / 订单明细_clean.xlsx5.2 参数化路径与执行日志为了让脚本不至于跑完就忘我还加了几行简单的日志输出把关键的过程信息打印出来。别看只是几行print它能在你第二次运行脚本、数据出现变化时快速判断清洗结果是否合理。df read_raw(raw_path) print(f原始行数: {len(df)}) df_clean clean_data(df) print(f清洗后行数: {len(df_clean)}) print(f成交金额缺失数: {df_clean[成交金额].isna().sum()}) write_output(df_clean, clean_path) print(f已输出: {clean_path})现在运行这个脚本屏幕上的输出是这样的原始行数: 3271 清洗后行数: 2858 成交金额缺失数: 41 已输出: output/订单明细_clean.xlsx等等清洗后行数怎么是2858我回头检查了一下原来是因为我在函数里加了dropna(subset[收货城市])把102行没有城市的记录删掉了又把未知金额的41行删掉了。这个数字和之前人工核对一致说明整体逻辑是靠谱的。看到这个输出我才有信心说今天的清洗过程是基本可靠的。5.3 异常兜底与大数据量下的两个性能建议脚本只对正常路径有效还不够。我补了一个简单的try-except结构把找不到文件、读取编码异常、写入失败这类常见错误统一兜住def main(): try: df read_raw(raw_path) except FileNotFoundError: print(f文件不存在: {raw_path}) return except Exception as e: print(f读取失败: {e}) return # 清洗与输出逻辑这一点在真实工作中尤其重要——数据清洗脚本往往是定时任务或批处理任务的一部分一个文件出问题不应当让整个任务崩溃而应该清晰地报告错误并继续处理下一个文件。另外针对以后可能遇到的大数据量场景我记录了两个来自今天的性能心得第一读取CSV时能用dtype参数指定列类型的就尽量指定比如把某些列指定为str能显著减少内存占用第二尽量避免用apply逐行处理百万级数据优先考虑正则替换、str操作、向量化计算。今天的1,200这种问题用df[列].str.replace(,, )会比apply函数快不少。最后再分享一个今天最大的体会。学习日记写到第六十七天我慢慢发现编程学习的真正分水岭不是你背了多少个函数而是你能不能对着一份真实且混乱的数据把要什么、怎么处理、为什么会出错想清楚。比如今天去重这一个动作代码只有一行但决定用哪个字段组合去重背后是对业务的理解。如果只学API不学思路下次换个数据照样抓瞎。所以我建议每个自学者都找一份真实场景里的脏数据亲手把它洗到敢拿去分析的程度这个过程踩过的每一个坑都比看十遍教程管用。
返回列表