用Pandas合并清洗35个CSV:编码、重复订单与统计口径实战
周一上午十点导师在实习群里甩了一个压缩包附一句话“第三次作业周五前交。”这份专业实习第三次作业的字面要求并不复杂把压缩包里的门店订单CSV合并成一张明细表做必要清洗另输出一张按门店和月份的销售统计表最后附一份清洗说明。但真正动手之后我才发现这次作业和前两次完全不是一个量级——前两次有明确的输入输出样例对上了就能拿分这一次没有任何过程要求只有结果要求。换句话讲导师想看的不是你写完了而是你敢不敢说自己的结果是对的。这篇文章就把这次作业从拆题、侦察、清洗、合并到交付的全过程完整记录下来包括我踩过的编码坑、分隔符坑和重复订单坑以及最后怎么从能跑走到能交付。希望能给正在实习、准备数据分析类作业、或者刚开始用pandas处理真实数据的朋友一点参考。1. 作业到手先别急着写代码把题目翻译成需求1.1 原始要求与我的理解压缩包里是35个CSV文件文件名大体是orders_门店名_月份.csv另外还有一张store_info.csv。作业要求用一句话就能概括合并所有门店订单文件清洗数据输出明细表和统计表。但必要清洗这四个字才是真正的题眼——它没有告诉你什么算脏、什么算干净也没有告诉你清洗到什么程度算及格。我把题目拆成了三件事把所有门店的订单明细合并为一张统一结构的表对日期、金额、数量、重复数据做规范化处理并记录每条处理规则基于清洗后的明细生成门店维度加月份维度的汇总表。三者缺一不可。第1步考的是数据读取能力第2步考的是数据质量意识第3步考的是业务统计能力。而附带的清洗说明文档考察的是你有没有能力把整个处理过程讲清楚。我后来才意识到这个文档的分量甚至比代码更重。1.2 这道题真正想考核的三个能力点第一是数据认知能力。拿到数据不看就直接合并是实习生最容易犯的错。作业里的数据不是教科书里的整洁数据它有编码问题、分隔符问题、列名混乱问题、日期格式不统一问题。这些问题如果不先摸清楚后面所有步骤都会在错误的基础上叠加错误最后得到一张看起来很正常、实际上完全不可信的表。第二是规范化能力。字段有没有统一、日期能不能排序、金额能不能求和、去重后订单数还剩多少这些决定了结果能不能被别人信任。规范化不是洗得越狠越好而是要有一套可以解释的规则。第三是交付能力。结果文件放哪里、怎么命名、清洗逻辑是否可复现、别人拿到你的数据能不能直接使用这些在学校作业里很少被要求但在职场里是基本功。这次作业的评分标尺我认为就是这三项。2. 动手前的数据侦察35个CSV到底长什么样2.1 先统计文件规模再逐个探查我第一件事不是打开编辑器写pd.concat而是先写一段探测脚本把每个文件的形状、列名、前两行打印出来import os import pandas as pd data_dir ./orders for fname in sorted(os.listdir(data_dir)): if not fname.endswith(.csv): continue fpath os.path.join(data_dir, fname) try: df pd.read_csv(fpath) print(fname, -, df.shape, |, df.columns.tolist()) except Exception as e: print(fname, - ERROR:, e)这段代码本身没有技术含量但价值在于把所有问题暴露在明面上。跑完之后我看到的问题整理成了一张诊断表问题类型涉及文件数具体表现UTF-8解码失败4个UnicodeDecodeError: utf-8 codec cant decode byte分隔符不是逗号3个读出来只有一个列列名变成整行内容列名不一致约10个订单号、订单编号、order_id混用列名含不可见空格2个打印列名看不出差别repr后才暴露日期格式多样几乎全部2024-06-01、2024/6/1、20240601并存看到这张表我心里反而踏实了作业里的脏是设计好的每种脏对应一个清洗点接下来要做的事情就是逐个击破。这一步让我节省了大量返工时间——如果直接合并再回头排查定位任何一个问题的成本都会翻几倍。2.2 用repr揪出隐藏字符有一个文件我一开始死活找不到问题列名明明看起来和别的文件一样但rename之后列名还是没变。我反复对比打印结果才想起来用repr()看一眼。结果发现列名订单号后面跟了两个空格肉眼看不出来字典的key匹配不上rename自然失效。这个案例非常典型。真实数据里的隐藏字符比想象中多得多可能是Excel导出时留下的也可能是系统中粘贴进来的。我的应对是把所有列名统一strip写进清洗流程的第一步。而且从那之后凡是涉及列名匹配的地方我都先执行df.columns [str(c).strip() for c in df.columns]再做其他操作。3. 编码和分隔符这两个隐形坑处理顺序不能乱3.1 编码问题的两种经典现场第一个现场是用默认的utf-8直接读旧系统导出的文件报UnicodeDecodeError。解决办法不复杂但处理顺序很重要不能一律用gbk去读因为新系统导出的文件是utf-8一律用gbk反而会把新文件读乱。更可靠的方案是try/except回退def read_maybe_gbk(path, **kwargs): for enc in (utf-8, gbk, utf-8-sig): try: return pd.read_csv(path, encodingenc, **kwargs) except UnicodeDecodeError: continue raise ValueError(f无法识别编码: {path})这里有个细节值得多说一句utf-8-sig和utf-8的区别。utf-8-sig在读取时会自动处理文件开头的BOM头某些Windows工具导出的CSV会带BOM直接按utf-8读虽然不一定报错但第一列的列名前面会多一个看不见的字符。所以我建议在探测时把三种编码都放进候选项按顺序尝试谁成功用谁。这种方式比较笨但在文件数量有限时最可靠也最容易读代码的人理解。第二个现场是输出端。作业要求的结果文件需要能被Excel直接打开而不乱码所以写入CSV时不能只用默认编码要显式加encodingutf-8-sigresult.to_csv(order_detail_cleaned.csv, indexFalse, encodingutf-8-sig)不少人在读取时注意到了编码却在输出时忘了这一步最后Excel打开全乱码前面所有清洗工作都白费了。这个错误我见过不止一次包括我自己也栽过一次所以印象特别深。3.2 分隔符混用比编码更隐蔽我记得有一个文件读出来df.shape显示(1000, 1)只有一列而且那列的内容是整行的拼接。这说明分隔符不是逗号。有的CSV用分号、有的用制表符最直接的办法是读取时显式指定分隔符df pd.read_csv(path, sep;)但前提是你要知道这个文件用的是分号所以我在探测脚本里会顺手打印df.head(2)确认读进来的结构是对的。pandas也支持sepNone加上enginepython来自动探测但那样在行数多时会明显变慢偶尔还会猜错所以我只在第一次人工确认时用它确认完就固定分隔符。真实项目中我还会写一个sep_map字典把文件名和对应分隔符关联起来而不是依赖自动探测。4. 清洗逻辑怎么定每一列都要有自己的处理理由4.1 列名统一与strip我把所有可能的列名映射到一个标准Schemacolumn_map { 订单号: order_id, 订单编号: order_id, OrderID: order_id, 下单日期: order_date, 日期: order_date, 门店: store_name, 门店名称: store_name, 商品分类: category, 类别: category, 数量: quantity, 单价: unit_price, 销售额: amount, 金额: amount, }执行顺序很关键先执行df.columns [str(c).strip() for c in df.columns]再做rename。否则映射可能因为不可见字符而失效。做完rename之后再检查一遍看看还有没有未映射的列名残留有就说明文件里还藏着没预料到的字段需要回到侦察阶段。这一小节看起来简单但它是整个清洗流程的地基。地基没打平后面每一层都是歪的。4.2 日期格式统一先判断形态再决定口径日期列是这次作业里我花时间最多的地方。不同门店导出的格式不同同一门店在不同月份也可能不一致。统一的思路不复杂全部交给pd.to_datetime但要注意两点。第一如果已知某张表是20240601这种格式直接指定format%Y%m%d会更精确解析速度也更快。对于格式不统一的文件用pd.to_datetime(series, errorscoerce)批量转换转换失败会得到NaT。第二不要急着把NaT删掉。我先统计了NaT的数量发现某个月份的文件里有几十行。仔细检查后才发现这些行的日期列被读成了数字类似45327这种Excel序列日期。pd.to_datetime不会自动识别Excel序列必须用pd.to_datetime(45327, unitD, origin1899-12-30)来转换。这件事让我总结出一条经验清洗前先分清楚数据是什么形态再决定使用什么口径。形态判断错了清洗函数再完美也是白搭。日期、金额、编码都是同一个道理。4.3 金额与数量的数值化激进正则加兜底校验销售额这一列藏着最多花活有的是1,234.00文本格式、有的是1,234.00、还有的末尾跟着元字。我的处理方式是先删除所有非数字字符保留小数点再用pd.to_numeric转成浮点数import numpy as np df[amount] ( df[amount] .astype(str) .str.replace(r[^0-9.], , regexTrue) .replace(, np.nan) .pipe(pd.to_numeric, errorscoerce) )这个写法的好处是无论原始列里是千分位、货币符号还是中文字符都能一次性清洗干净。缺点是过于激进如果原始数据里小数分隔符是逗号比如欧洲格式1.234,56就会误伤成1.23456。我在作业场景下把概率估得很低但加了一个校验步骤兜底转换完成后检查金额列的数值范围如果出现异常巨大或异常偏小的值再回头查看原始数据。数量列的坑集中在0和负数上。业务上可以推断订单数量不应该为0或负数但真实文件里确实出现了往往是补录或冲销数据。我的规则是数量为负且金额为负的行是红冲记录属于有效业务但不在本次统计范围内单独输出到异常表数量为0或负但金额为正的判定为填写错误直接剔除并记录原因。写到这里我想强调一个观念清洗不是把所有看着不对的都删掉而是对每一条处理决策给出理由。给每一类异常设计一个明确策略就是给数据一个身份归属。这一步做得越细后面被追问为什么删这些行的时候就越从容。5. 合并与汇总重复订单和统计口径是这次作业的重灾区5.1 合并后的重复订单排查所有文件处理完之后我用pd.concat把它们拼成一张完整的DataFrame。本以为清洗阶段已经把所有问题解决了直到我随手算了一下总行数和唯一订单数完整行数36271去重订单数34380差额约1900条多出来的这1900条就是重复订单。排查过程是这样的先按order_id分组找出记录数大于1的订单号再打印某一个订单号的所有行看差异在哪。打印结果让我意识到重复至少有两种成因同一订单在不同月份文件里各出现一次因为月末切割档案时上月末未完结订单被新月份文件再次导出同一文件内部有完全相同的两行可能是系统重推。针对这两种情况处理策略不一样。跨文件重复我保留关键字段更完整、金额更大的那一行文件内完全重复保留第一行即可。落地写法如下df[_fields] df[[amount, order_date, store_name]].notna().sum(axis1) df ( df.sort_values([_fields, amount], ascending[False, False]) .drop_duplicates(subset[order_id], keepfirst) .drop(columns[_fields]) )这里面的逻辑是优先保留信息更完整的行完整度相同时保留金额更大的行。这个方案不完美但在没有时序信息的情况下它至少可解释、可复现。导师后来在评语里专门标注了规则可解释这一点说明他看重的不是去重本身而是去重背后有没有业务判断。5.2 分组汇总的统计口径要先定汇总表看似只是一个groupby的问题其实藏着统计口径问题到底是按订单金额汇总还是按订单笔数汇总究竟是一个门店一个月为一行还是一个门店一个月一个品类为一行我最终选了门店、月份、品类三个维度同时输出门店月度汇总和品类月度汇总。分组代码大致如下summary ( df.groupby([store_name, month, category], as_indexFalse) .agg( order_count(order_id, nunique), total_qty(quantity, sum), total_amount(amount, sum), ) ) summary[total_amount] summary[total_amount].round(2)注意这里的order_count用的是nunique而不是count目的就是防止明细表还有残留重复时汇总口径被污染。另外浮点数sum之后可能出现类似0.1加0.2的精度尾巴所以我统一round到两位再输出。这一步的教训是统计口径必须在写代码前想清楚而且要写进清洗说明文档里。否则别人看你的表会默认你算的订单笔数、销售额都是同一个口径一旦口径混乱结果就失去可比性。6. 从能跑到能交付提交前按这个清单自检一遍6.1 三查总量、订单、金额我给自己定的自检清单核心是回答三个问题清洗后的总行数是多少清洗前是多少差异能不能被解释去重后的订单数是多少和原始订单号总数差多少汇总表里的总销售额能不能用手工抽样对回来这三个问题看着简单做起来花了我一个下午。比较反直觉的是有一张表清洗前金额汇总和清洗后金额汇总对不上差了大概两千块。最终定位到原因某个文件在编码回退到gbk时多读了一个隐藏的分页汇总行。这个门店的导出工具会在每个月的数据中间插入一行总计该行的字段布局和正常数据完全不同直接导致那一段数据被读错。应对办法也写在自检流程里每个文件清洗前后都单独打印shape和sum(amount)哪个文件差异大就先处理哪个不要等合并之后再回头找。合并之后的问题定位成本是指数级上升的这个道理越早明白越省时间。6.2 交付物的命名与清洗说明我的最终交付是三个文件文件名内容order_detail_cleaned.csv清洗后的订单明细store_month_summary.csv门店月份汇总cleaning_notes.md清洗说明文档cleaning_notes.md里写了四块内容原始数据有什么问题、我做了什么处理、处理依据是什么、处理后数据应该怎么读。写这个文档一开始觉得是负担但后来发现它对自我检查特别有帮助——当你能把一条清洗规则讲清楚时才会发现自己是不是真的想明白了。文档里我还保留了一个已知限制小节比如欧洲格式金额没有处理、部分日期缺失但无法追溯等。这个小节让我显得不那么完美但反而增加了可信度。数据工作的可信度不是靠声称完整而是靠诚实记录不完整的地方。7. 回头复盘这次作业最值钱的部分不是代码而是先侦察再动手的节奏7.1 一开始的错误试图写一个大而全的函数我第一次犯的错是想把35个文件的编码、分隔符、列名统一、日期规范全部装进一个大函数里一次跑完。结果是我写了一个二百多行的脚本里面全是条件分支跑完第一遍报错修完一个分支另一个分支又爆整个过程又累又挫败。后来我改成逐文件加载、快速检测、按规则清洗、落盘中间结果的分步流水线。每个文件在逻辑上独立处理中间结果以CSV形式存下来任何一步出问题我只需要看那一步的输出就能定位。这算不上高级技巧但它把一个不可调试的大黑盒拆成了三个可观测的小步骤。中间结果落盘虽然多占了一点磁盘空间但调试效率提升非常明显。7.2 想明白数据准比数据多重要之后导师在看完我的交付后问我的第一个问题不是你怎么实现的而是你怎么确定结果是准的。这个问题让这次作业的真正价值浮出水面实现能跑的代码只是及格线能够证明交付结果是可信的才构成完整的工作闭环。这也是我在自检阶段花了一下午做三查的原因——不是刻意追求完美而是因为我知道之后一定会被问这个问题。如果让我给下一批实习同学唯一一条建议我会说拿到作业先别打开编辑器先打开压缩包里的原始文件把每个文件都翻一翻把问题写成一张清单再开始写代码。代码只是你认知的翻译器认知错了代码再漂亮也没有意义。这次专业实习第三次作业我写了三百多行代码但最后留在印象里的不是那些清洗函数而是侦察阶段列出的那张脏数据清单。它让我真正体会到数据处理这份工作第一步不是写代码而是先学会看数据。