ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

CSV清洗实战:先标准化再去重,避免drop_duplicates误删漏删

2026/9/15 13:48:57 拓冰建站 浏览量
CSV清洗实战:先标准化再去重,避免drop_duplicates误删漏删 做本地 CSV 清洗的时候很多人第一反应就是拿到文件赶紧drop_duplicates()把去重当成一个独立动作直接开干。我最初也这么干过结果被一份 20 万行的客户表狠狠教育了一次看起来一模一样的“张三”其实是“张 三”和“张三”还有一半是半角括号、一半是全角括号的备注直接一上去重该去的没去掉不该删的倒删了不少。后来我慢慢把清洗流程固定成“先标准化、再去重、最后留报告”这中间其实藏着很多琐碎但关键的细节。这篇文章我就把自己在本地处理 CSV 时踩过的坑、验证过的顺序、写过的代码模板和保留检查报告的思路完整梳理一遍内容基于我平时用 pandas 处理本地文件的实践也顺带聊一聊 SQL 场景下的去重差异。适合经常跟 CSV 打交道的分析、测试、运维和数据开发朋友尤其是那些被“数据看起来重复但去不掉”折磨过的人。1. 整体设计与思路拆解先说一个特别核心的判断去重不是清洗的第一步更不是清洗的全部。真正决定去重效果好不好的是去重之前你有没有做标准化。很多人之所以“去重没效果”往往不是drop_duplicates()用错了而是源数据里那些本应该相等的值在字符层面就是不相等的。1.1 为什么标准化一定要在去重之前我打个比方你手里有一份会员名单里面记录着同一个人的三种写法“李 磊”、“李磊”、“LI LEI”。你用 Excel 的删除重复项或者 pandas 的drop_duplicates()去跑这仨一条都不会被当成重复因为程序判断的是字节是否完全相同它不认识“中文空格和没空格其实是一个人”。再比如日期列一个文件里混着2024-01-05、2024/1/5、20240105三种格式。你在不统一格式的情况下直接去重结果是五条记录全留下但实际上可能是同一天的三条记录碎片。标准化的本质就是先定义清楚“什么叫做相等”然后把那些因为录入习惯、编码差异、格式不统一造成的“看似不同实则相同”统统拉到同一个基准线上。去重是等值判断而标准化是在给“等值”制定规则。这个顺序还有一个隐藏的好处标准化做完之后数据本身也变得更规整了后续你要做归因分析、聚合统计、跟别的表 join都会顺手很多。它不是为了服务去重一个动作而是让整份数据进入一种“可以被信任”的状态。1.2 直接去重的三个典型代价如果跳过标准化直接去重我观察到至少三类典型问题会轮番冒出来。第一类是重复项漏网。前面说的全角半角、中英文括号、不可见字符、大小写差异都会让本应匹配的记录在字符层面错位。你看着屏幕上两行明明一样但程序就是不认。第二类是误删有价值的数据。举个例子某数据源的“订单编号”列在录入时被填成2024-001和2024-001多了一个尾部空格。如果你把它当成关键列去重程序会选择保留一行但这两行如果分别来自不同渠道那你就把渠道信息给删丢了。更麻烦的是如果你不设置keep参数可能删的还不是你想保留的那条。第三类是标准不统一导致后续报告没法追溯。你没做标准化报告里只能说“删除了 N 行”但你解释不清这些行为什么是重复的也没办法沉淀成规则。下一次换一个人来处理同样的数据他又得从零开始摸索。清洗逻辑不可积累、不可复用这比单次清洗出错还要浪费。所以我的固定流程是先定义清洗列再做标准化然后设计去重键最后用报告把“为什么删、删了什么”固定下来。2. 标准化的核心细节与实操要点标准化不是一个玄乎的词落到本地 CSV 上就是一件一件具体的事处理空白字符、统一大小写和全半角、修正编码、规整日期和数字格式、处理同义别名。每个细节看起来小但加在一起效果完全不同。2.1 空白字符和不可见字符的处理CSV 文件是从各种系统里导出来的最常见的脏数据就是空格。但空格也分好几种普通空格、全角空格、制表符、换行符、非断行空格肉眼几乎分不出来。我处理空白字符的固定函数长这样import pandas as pd import re def clean_whitespace(df, cols): for col in cols: df[col] ( df[col] .astype(str) .str.replace(\u3000, , regexFalse) # 全角空格 .str.replace(\xa0, , regexFalse) # 非断行空格 .str.strip() # 首尾普通空格 .str.replace(r\s, , regexTrue) # 中间多个空白压缩成一个 ) return df有个细节值得单独提出来strip()只处理首尾如果“张 三”中间出现多个空格你还是得用str.replace(r\s, , regexTrue)把它压成单个。但也要小心这行代码会把所有连续空白包括换行符都替换成空格如果你的某个字段本身就是要保留换行的备注那就不要对那一列做这个操作。我在实际项目中建议把清洗范围限定在“确定要做去重键”的那些列不要一上来对全表所有列无差别处理。比如地址列里的内部换行有时候是有意义的你把它压成一行反而丢了结构化信息。2.2 大小写、全半角与排版字符统一大小写问题多发生在英文名、邮箱、订单号、商品编码这类列上。定规则之前先问一句这个列的大小写到底有没有业务含义比如商品货号AB-100和ab-100如果对应同一个 SKU那就可以统一成大写但如果有的系统里大小写代表不同批次你统一后就出大问题了。我一般的做法是对“用于匹配的列”统一转为小写或大写同时把原始列留一个备份用于人工复核。另外一定保留一个原始列不要直接覆盖防止后面发现规则定义错了还能回退。全角半角的问题最简单粗暴的方法是直接用 Python 的unicodedata做 NFKC 规范化import unicodedata def to_half_width(s): return unicodedata.normalize(NFKC, str(s))NFKC 会把全角英数字、全角标点转成半角把一些特殊字符也做兼容分解。它很方便但要注意它也会把一些看起来是中文的字符做变换比如 RomanNumeral 之类的字符。如果数据量小我会先跑一个采样列出来 diff 一下确认没有意外变更再全量执行。日期格式统一算是标准化里比较高频的事项。我习惯先让 pandas 自动推断一次能转的就统一成YYYY-MM-DD不能转的单独挑出来人工看df[date_std] pd.to_datetime(df[input_date], errorscoerce).dt.strftime(%Y-%m-%d)s参数这里很关键它会把无法解析的值变成NaT不会让整列报错。改完一定要检查NaT的占比如果超过可接受范围说明这个列里可能还有特殊格式甚至根本不是日期单纯靠to_datetime是不够的你得先对着样本数据看一遍。2.3 数字格式与业务别名映射数字列里常见的坑是千分位逗号、百分号、货币符号混在数值里导致1,200被当成字符串。我的做法是清洗时先把这些字符拿掉再转成数值并且统一小数位def clean_numeric(s): if pd.isna(s): return pd.NA s str(s).replace(,, ).replace(¥, ).replace(%, ) try: return round(float(s), 2) except ValueError: return pd.NA业务别名的标准化则需要一张映射表。比如城市列里“北京市”、“北京”、“北京市市辖区”其实指向同一个城市没有映射表的话去重永远去不干净。本地处理时我通常会手工维护一份alias_map然后.map()上去alias_map {北京市: 北京, 北京: 北京, 市辖区: 北京} df[city_std] df[city_raw].map(alias_map).fillna(df[city_raw])映射表里最麻烦的是“一个别名对应多个标准值”的情况比如“苹果”可能是水果也可能是公司名。这种歧义类目我不建议硬做规则标准化宁可把它标记成“待人工确认”纳入报告而不是猜一个标准值进去把数据搞错。3. 去重逻辑与核心实现标准化做完去重才真正开始发挥作用。但“去重”这两个字背后还得分清楚你到底要按什么维度去重、重复之后保留哪一行、以及被删掉的行如何不彻底消失。3.1 先定义清楚你的“重复”是什么有人做整行去重也就是只要所有字段都一样就删有人做按键去重也就是说只用id或email作为判断依据。这两种场景差异很大。整行去重适合那种同一张表重复导入了多次的场景技术上直接df.drop_duplicates()就行。但更常见的情况是你想按某个业务主键去重比如同一个用户多条记录只要保留一条。这时就得用subsetdf_dedup df.drop_duplicates(subset[user_id], keepfirst)keep参数我建议每次写代码都显式声明别偷懒。keepfirst表示保留第一次出现的行keeplast保留最后一条keepFalse会把重复的全部删掉意思很危险一般用于你想筛出“纯唯一”记录的时候。还有一类特殊情况SQL 中的SELECT DISTINCT会把 NULL 视为同一个值所以多行 NULL 会被合并但 pandas 中drop_duplicates()对NaN的处理是类似的也会视为相同的值并去重。这里要特别小心如果你用None和NaN混着填可能会得到跟你预期不一样的结果我建议在去重之前先统一缺失值的填充方式比如填一个特殊标记__MISSING__或先把缺失行的主键列单独抽出来处理。3.2 不直接删行而是先打标记直接删行有个隐患一旦删完发现规则错了原始数据又没备份恢复起来特别痛苦。所以我现在的去重流程都是“先标记、再筛选、再导出报告和删除清单”df[row_id] range(len(df)) df[is_duplicate] df.duplicated(subset[user_id], keepfirst) duplicated_rows df[df[is_duplicate]] kept_rows df[~df[is_duplicate]] duplicated_rows[[row_id, user_id]].to_csv(removed_rows.csv, indexFalse)is_duplicate这个布尔列非常有用。它能告诉你这条记录是从哪一行来的也能让你随时检查这一批重复是不是误判。后续如果发现规则需要调整你拿removed_rows.csv按row_id并回去就行不用重新跑全量。3.3 半结构化冲突重复但细节不同怎么办最考验人判断力的是那些主键相同但其他字段值却不一样的重复数据比如同一个user_id下一行手机号填了 138 开头另一行填了 139 开头。直接删掉任何一行都会丢信息比较稳妥的做法是分组聚合。大致逻辑是按主键分组对不同列使用不同的聚合策略agg_rules { phone: lambda x: x.dropna().iloc[0] if not x.dropna().empty else pd.NA, age: max, tags: lambda x: |.join(set(x.dropna().astype(str))) } df_merged df.groupby(user_id, as_indexFalse).agg(agg_rules)文本列我通常取非空值里最早出现的那一条数值列按业务逻辑取最大、最小、平均或求和标签类列表列就合并去重。这种处理本质上已经不是简单的“去重”而是“按主键合并碎片”它保留的信息更多报告里也更容易解释清楚。3.4 哈希去重与大文件场景我有时候会看到有人用整行做 MD5 哈希再去重性能确实比在全文本上直接比较要快。尤其是十几万行列数又多的情况为每一行生成一个hash列然后按hash去重速度会好很多import hashlib def row_md5(row): raw |.join(str(x) for x in row) return hashlib.md5(raw.encode(utf-8)).hexdigest() df[row_hash] df.apply(row_md5, axis1) df_dedup df.drop_duplicates(subset[row_hash], keepfirst)但这里有两个限制我得说清楚。第一MD5 是文本级的精确匹配它对“虽然内容不一致但业务上指向同一实体”的情况完全无能为力。如果你要的是基于业务语义去重哈希解决不了。第二MD5 本身有极低的碰撞概率虽然小到可以忽略但在对账等强审计场景下不能把哈希结果当成唯一真理。我的经验是MD5 更适合快速筛出明显重复像“同一模版重复导了几遍”的情况它非常高效但遇到需要语义归一、近似匹配的场景还是得回到标准化的规则上来。4. 报告如何设计才能让清洗结果可检查、可回溯这一节是我自己后来特别重视的部分。清洗完 CSV 之后别人最常问的问题就是你到底改了什么删了什么为什么删你总不能口头说“我觉得都是重复数据”。留一份检查报告既是给自己留证据也是让协作的人能看懂清洗过程的唯一途径。4.1 报告里应该包含什么我的报告分四层。第一层是元信息包括源文件路径、脚本版本、运行时间、总行数、总列数。没有元信息的报告过两周再回来看就是一堆数字毫无意义。第二层是标准化操作记录每执行一条规则都要统计受影响的行数。比如“去除全角空格影响 3421 行”“统一日期格式影响 1867 行”。这样能精确判断哪条规则对数据集冲击最大也方便后续调规则。第三层是去重明细包括去重键、去重前行数、去重后行数、删除行数、保留策略first/last/merged。如果用了分组聚合还要把每列的聚合规则写清楚。这里我还会附一个抽样表把删除的样本行拿出来最多展示 20 到 50 行就够人工审计了。第四层是质量指标比如标准化之后空值数量、唯一值数量、日期解析失败数量。这些指标能侧面反映数据质量是不是在变好。4.2 用 JSON 记录结构化报告我喜欢用 JSON 存报告因为它是结构化的后面要拿去做统计、接监控、挂到某个页面上展示都方便。每次跑完清洗脚本就生成一个report.json{ source_file: customers_raw.csv, output_file: customers_clean.csv, timestamp: 2025-06-01 10:24:00, script_version: v1.2.0, input_rows: 120000, output_rows: 118674, removed_rows: 1326, dedup_key: [user_id], dedup_policy: keep_first, operations: [ {rule: strip_fullwidth_space, affected_rows: 3421}, {rule: lowercase_email, affected_rows: 872}, {rule: parse_date, affected_rows: 1867, parse_failed: 12} ] }这个 JSON 的好处是机器可以读人也可以读。将来如果数据出问题你拿着这份 JSON 和脚本就能完整复制当时的清洗过程。4.3 保留删除样本与人工复核通道除了总的摘要我强烈建议把被删掉的行单独导出成一个removed_rows_sample.csv里面除了原数据再加一个remove_reason列写明这一行是因为跟哪个user_id重复而被删除。比如user_id998877 与 row_id231 重复保留 row_id231这个动作看起来只是多写了一文件但实际作用非常大。有一次客户方质疑我们“删错了 VIP 用户”我直接把这个样本文件拉出来按remove_reason一过滤清清楚楚看到它只是某个用户的一条重复注册记录原始的主记录还完整保留着几分钟就把争议解决了。没有这份样本这件事就会变成“你们把数据搞丢了”。我在脚本里会写两个导出一个duplicated_rows_full.csv保存所有被标记为重复的行一个removed_rows_sample.csv只在行数很多时抽样保存。前者用来兜底后者用来快速给人看。两份文件大小和用途不一样不要混在一起。4.4 用 Markdown 生成人类易读摘要JSON 适合给机器和研发看但业务同事更习惯看格式化的摘要。我通常会让脚本顺带生成一份report.md把去重前后对比、规则影响、删除样本表都写进去## 清洗摘要 - 输入行数120000 - 输出去行数118674 - 删除行数1326 ## 关键操作记录 | 规则名称 | 受影响行数 | 说明 | | --- | --- | --- | | strip_fullwidth | 3421 | 去掉全角空格 | | lowercase_email | 872 | 邮箱统一小写 | | parse_date | 1867 | 统一为 YYYY-MM-DD | ## 删除样本 | row_id | user_id | remove_reason | | --- | --- | --- | | 10086 | 998877 | 与 row_id231 重复 |这份 Markdown 可以直接贴到工单、周报或者项目文档里别人不用跑代码就能知道你干了什么。5. 常见问题与排查技巧实录这里我把实际处理 CSV 时遇到的几个高频问题集中列一下每一个都是我踩过或帮别人排查过的坑希望能帮你少走弯路。5.1 为什么去重后行数几乎没有变化如果你已经跑了drop_duplicates但发现删掉的行数少得离谱十有八九是标准化没做到位。最典型的元凶是全角空格、全角括号、竖线符号这些肉眼看不到的字符。排查方法很简单你把那些“看起来重复”但没被删的行打印出来用repr()看一眼原始结尾和中间字符for s in sample: print(repr(s))一旦看到\u3000或\xa0说明就是特殊空格在捣乱回到标准化函数里补一条替换规则就好。另一种可能是日期或数字列的类型不统一。比如1被 pandas 读成了整数而另一处是1.0浮点数drop_duplicates会认为1 ! 1.0所以去重失效。这种情况需要先统一类型把数字列统一转成字符串或统一保留小数位后再去重。5.2 Excel 打开正常Pandas 读出来乱码或读不出来这是本地 CSV 场景里特别高频的问题。CSV 文件本身没有指定编码Excel 默认用本机 ANSI 编码打开中文系统上通常是 GBK而 pandas 默认读取编码是 UTF-8两者对不上就会乱码或报UnicodeDecodeError。更麻烦的是如果你用 Excel 打开一个 UTF-8 文件后“另存为 CSV”Excel 会在文件头写入一个 BOM导致用 py 读出来第一列列名带\ufeff前缀。我的建议是读取阶段统一用encodingutf-8-sig它会自动把 BOM 去掉如果确认文件是 GBK就改用encodinggbk重新读。尽量不要在清洗阶段去猜编码最好写一个自动嗅探函数先尝试 UTF-8再尝试 GBK最后再报错。5.3 日期规范化把有效数据变成 NaT 了pd.to_datetime(..., errorscoerce)会把解析失败的行变成NaT但如果你不做统计可能根本发现不了有数据被静默置空了。所以我每次跑完日期转换都会先看看NaT的占比并把NaT的行单独导出。有些日期列里混着20240105、2024-01-05 12:00:00、05/01/2024这种混合格式to_datetime偶尔也会自作聪明去推断。如果格式太乱又必须严谨我建议写一个自定义解析函数按正则分情况处理而不是盲目依赖自动推断。自动推断适合“粗略处理”不适合“严格治理”。5.4 去重后数据总量变少但业务指标反而对不上了这种情况往往是因为你没有先想清楚“保留哪一条”。比如同一个用户有两条记录一条带手机号、一条没有你如果设了keepfirst而刚好先出现的是没手机号的那条去重后这个用户的手机号就丢了后续打电话营销就会漏掉这个客户。更稳妥的做法是前面提到的分组聚合策略主键相同的记录把非空字段合并起来而不是粗暴地只留一行。如果数据量大、字段多至少要人工先对几条样本做策略验证确认keep顺序符合业务预期再全量跑。我在项目里一般会按时间倒序排好让“最新的一条”成为保留项这样更贴合大多数业务场景。5.5 报告文件越撑越大导致清洗脚本跑得很慢报告如果每行都全量写数据量大时确实会影响性能。我处理几百万行 CSV 的时候不会把删除清单导出全量 CSV而是只保留一份removed_rows_sample.csv和一份duplicated_keys.csv。这两个文件加起来可能还不到原始数据量的 1%但已经足够回答“删了什么、为什么删”。完整的数据备份交给另一个归档目录日常不需要加载到报告里。这里也提醒一个细节写报告前先做好采样不要留着 DataFrame 在内存里反复to_csv。一次性把需要写出的内容都算完、再依次写盘能省不少时间。最后再分享一个实际操作中的小技巧我现在每次清洗 CSV都会先复制一份原始文件放到archive/目录里文件名带时间戳。看起来这个动作平平无奇但它帮我躲过好几次“规则写错了但数据已经被覆盖”的灾难。清洗是容易贪快的事但越是看起来简单的本地数据处理越值得多留一条退路。报告不是做给领导看的是做给“下个月的自己”看的这句话我特别认同。你这次把标准和报告留好了下次处理同类数据时就能直接拿之前的规则和代码复用而不是重头再来。