上个星期我又踩了一次数据清洗的老坑。手里一份三十多万行的渠道商名单CSV,从两个系统导出后直接拼接,然后我习惯性地先做了个整行去重。等统计完给业务方一看,对面直接说数不对——同一个门店,在A系统里联系电话是“138-0000-0000”,在B系统里是“13800000000”,中间的分隔符不一样,我按整行去重根本识别不出这是同一条记录。那天下午我基本就耗在返工上了。
后来我把这套处理流程重新理了一遍,总结出一个很朴素的结论:CSV清洗的正确顺序,应该是先做标准化,再做去重,最后把每一步操作都留下可检查的报告。标准化不到位,去重就只是走过场;去重不留报告,后面谁也没法确认你到底删了什么、凭什么删。这篇文章我把完整的思路、代码和实际踩过的坑都写出来,给同样需要处理本地CSV、又不想在细节上翻车的人做个参考。无论你是用pandas、SQL还是纯命令行工具,这个处理顺序和检查逻辑都是通用的。
1. 先把顺序理清楚:标准化前置,去重才有意义
很多人在处理CSV时的第一反应就是“我要去重”,这个直觉没有错,但如果数据本身还没有统一表达形式,去重这个动作就无从谈起。去重的本质是判断两条记录是否在某个特征维度上等价,而“等价”的前提是特征的表达方式一致。先标准化、再去重,本质上是让比较的基准先成立。
1.1 同一个商户,为什么在名单里出现了两次
我给你构造一个极常见的例子。下面这张表只有三个字段:门店名称、联系电话、合作状态。
| 门店名称 | 联系电话 | 合作状态 |
|---|---|---|
| 天河店 | 138-0000-0000 | 正常 |
| 天河店 | 13800000000 | 正常 |
| 天河店 | 138 0000 0000 | 正常 |
肉眼看一眼就知道,这是同一个店,三条记录说的是同一件事。但如果直接跑整行去重,这三行会因为字符串级别的差异被判定为互不相同,全部保留下来。原因很简单:138-0000-0000、13800000000、138 0000 0000在字符层面确实不是同一个值。
类似的场景还有日期。2024/01/05、2024-01-05、20240105、2024年1月5日在语义上是同一天,但在CSV文件里是四种完全不同的字符串。如果不先把日期统一成一种格式,去重时就等于睁着眼睛漏掉重复数据。
所以,标准化的核心作用是什么?是把“语义相同但形态不同”的值收敛成同一种形态,让后续去重能在统一的基准上进行。这一步不做,后面所有的去重逻辑都是在沙地上建楼。
1.2 标准化不到位的“假唯一”,比重复更坑
重复数据容易发现,因为至少你看得出记录多了一行。“假唯一”才是更隐蔽的问题——它披着“每条都不一样”的外衣,实际上在业务层面是同一条实体。这种问题不会让文件看起来异常,但会让后续的统计结果整体虚高。
举一个我实际遇到过的例子。我曾经清洗过一份会员数据,按“邮箱”字段去重后保留了近十万条记录。后来业务方做营销前抽样检查,发现里面有不少邮箱看起来“差不多”,比如ZhangSan@gmail.com和zhangsan@gmail.com、zhangsan@gmail.com和zhangsan@gmail.com(注意末尾空格)。这些记录在去重时全部被保留,因为字符串不完全相等。可它们其实是同一个人的同一个邮箱。
这就是“假唯一”的破坏力:统计数据看起来没问题,业务执行时才发现名单里有大量重复的触达对象。更麻烦的是,如果标准化滞后,文件已经被下游系统导入,再回头修数据就要牵扯一堆联动。
所以要控制清洗质量,不是看去重后发现多少重复,而是看标准化后还能不能发现新的重复。我个人的习惯是:标准化之后专门跑一次“重复率对比”,看看同一批数据在标准化前和标准化后,重复行数分别是多少。如果标准化后的重复数量明显增加,说明前面的标准化工作起到了真正的收敛作用;如果几乎没变化,那就要怀疑是不是某些字段没有处理干净。
1.3 用SQL做清洗时,顺序同理
有人可能觉得这套逻辑只适合pandas这类数据处理工具,其实用SQL做CSV清洗时,顺序也一样。SQL里最常见的去重写法是:
SELECT DISTINCT store_name, phone FROM raw_table;或者用窗口函数:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY store_name, phone ORDER BY updated_at DESC) AS rn FROM raw_table ) t WHERE rn = 1;问题在于,如果表里的phone字段有的带横线、有的不带,有的首尾带空格,上述SQL在去重时依然会把同一个人当成两个不同的人。所以在SQL里正确的做法是先做一步标准化,比如:
SELECT TRIM(store_name) AS store_name, REGEXP_REPLACE(phone, '[^0-9]', '') AS phone FROM raw_table;等字段形态统一了,再放进DISTINCT或ROW_NUMBER()里去重。逻辑跟pandas完全一样,只是工具不同。
2. 标准化的落地清单:编码、字符串、空值、日期与数值都要管
标准化到底要做哪些事?这是我被问得最多的一个问题。很多人的理解是“把数据变好看一点”,实际上标准化的对象非常具体,可以拆成编码与表头、字符串清理、空值统一、日期与数值归一这几大块。每一块都有对应的坑。
2.1 编码和表头:先在读取阶段解决一半问题
CSV文件最常见的一个坑是编码问题。用Excel导出的CSV,很多是GBK或GB18030编码;用pandas直接按默认UTF-8读取,出来的就是乱码。还有一种情况是读取时是好的,但写完CSV后再用Excel打开变成乱码,这通常是因为缺少BOM头。
处理这个问题,最省心的办法是在读文件时显式指定编码,并且在写文件时带上utf-8-sig:
import pandas as pd # 读取时指定编码,避免乱码 df = pd.read_csv('raw_data.csv', dtype=str, encoding='utf-8') # 如果上面的编码不对,试试 gbk # df = pd.read_csv('raw_data.csv', dtype=str, encoding='gbk') # 写出时用 utf-8-sig,这样 Excel 打开不会乱码 df.to_csv('clean_data.csv', index=False, encoding='utf-8-sig')这里有个小经验:dtype=str非常重要。CSV里的电话号码、订单号这类字段,如果不强制指定为字符串,pandas会把它们转成数字,导致前导零丢失、长数字变成科学计数法。比如订单号0123456789会被读成123456789,这种数据一旦进入去重环节,就会产生“看起来相同其实被截断”的假重复,或者反过来丢掉有效信息。所以,凡是长度超过15位、或者可能带前导零的字段,我建议一律用dtype=str读取。
表头标准化也是容易被忽略的环节。同一份数据在不同系统里导出的列名可能不一样,比如StoreName和store_name、店铺名称和门店名称,如果不去统一,后续代码写起来会很痛苦。我一般会在读取后先做一次列名归一:
df.columns = [col.strip().lower().replace(' ', '_') for col in df.columns]把列名统一成小写下划线风格后,后面所有代码都不会因为列名大小写不一致而踩坑。
2.2 字符串和空值:把“看不见的差异”处理掉
字符串清洗是标准化中最细碎的部分,也是最容易漏的部分。下面这些场景都是我在真实数据里碰到过的:
- 字符串首尾有空格,肉眼看不出来,但
strip()之前它就是和没有空格的字符串不相等; - 全角空格
\u3000,常见于中文输入法环境下编辑过的CSV,str.strip()默认处理不了; - 括号有的是英文括号
(),有的是中文括号(); - 数字和单位之间有时有空格,有时没有。
处理这些“看不见的差异”,我建议写一个统一的清理函数,把每一列都过一遍:
def clean_string_series(s): return (s .astype(str) .str.strip() # 去掉首尾半角空格 .str.replace('\u3000', '', regex=False) # 去掉全角空格 .str.replace('(', '(', regex=False) .str.replace(')', ')', regex=False) .str.replace(r'\s+', '', regex=True) # 去掉中间多余空白,按需使用 )注意str.replace(r'\s+', '')这步要谨慎。电话号码这类字段字中间的空白确实应该去掉,但地址字段如果去掉所有空白,可读性会变差。更合理的做法是对不同列使用不同策略:身份证号、电话号、订单号这种标识类字段做“去所有空白”处理;名称、地址这类字段只做首尾去空格和全角转半角。
空值也是个老大难。在CSV里,“空”的表达方式太多了:单元格完全空着、字符串"NULL"、"NA"、"N/A"、"None"、"-"、"未知",甚至四个空格。如果不统一,后面做统计或者去重时,每个值都会被当成不同的东西。
我的做法是先统一成一个标记,再按业务需求决定是填空还是丢弃:
na_placeholders = ['', 'NULL', 'NA', 'N/A', 'None', 'null', '-', 'nan'] df = df.replace(na_placeholders, pd.NA) # 如果业务上需要空值参与比较,可以先填充一个统一占位符 df = df.fillna('')这里有一个关于去重的坑值得单独说:pandas里的drop_duplicates()会把所有NaN值视为同一个值。如果某一列大量缺失,做整行去重时这些缺失行内部会被互相判定为重复。这个问题我会在第3章详细展开,但标准化的意义就在于:先把缺失值处理成一个明确的标记,再去重,你才能准确知道“重复”到底是发生在有效数据上,还是被缺失值搞出来的假象。
2.3 日期与数值统一:避免同值不同形
日期字段是“同值不同形”的重灾区。一份CSV里,同一个时间点可能被写成2024/01/05 10:30:00,也可能被写成2024-01-05,还可能是Excel导出的序列号。要统一,最省心的是交给pandas.to_datetime解析后格式化成同一种字符串:
df['signup_date'] = pd.to_datetime(df['signup_date'], errors='coerce') df['signup_date'] = df['signup_date'].dt.strftime('%Y-%m-%d')这样无论源数据里是哪种格式,最后都会统一成YYYY-MM-DD。要注意的是,errors='coerce'会把无法解析的日期变成NaT,这些坏数据会在后续检查时被单独找出来,而不是默默参与去重。
数值字段也类似。有些CSV里的金额是1,234.56带千分位,有些是1234.56,有些甚至带货币符号¥1,234.56。去重前如果不把这些统一成纯数字,1,234.56和1234.56就会被当成两个不同值。处理方法是用正则把非数字字符去掉:
def clean_numeric_series(s): return (s .astype(str) .str.replace(r'[^0-9.\-]', '', regex=True) .replace('', pd.NA) .astype(float) )这块我的建议是:数字字段的可读性让位给可比性。清洗阶段的目标是让相同语义的值在字符层面完全一致,美观是后面展示环节的事。
2.4 标准化代码的推荐写法
把上面这些逻辑串成一个函数,是提升复用率的关键。我习惯一次性把常见的标准化步骤封装起来,每接一份新CSV,先跑一遍通用清理,再做针对性的字段处理:
def normalize_dataframe(df): df = df.copy() # 1. 列名标准化 df.columns = [str(c).strip().lower().replace(' ', '_') for c in df.columns] # 2. 字符串列统一清理 str_cols = df.select_dtypes(include=['object']).columns for col in str_cols: if col not in ['signup_date', 'amount']: # 排除需要单独处理的列 df[col] = (df[col] .astype(str) .str.strip() .str.replace('\u3000', '', regex=False) .str.replace('(', '(', regex=False) .str.replace(')', ')', regex=False)) # 3. 空值占位符统一 df = df.replace(['', 'NULL', 'NA', 'N/A', 'None', 'null', '-', 'nan'], pd.NA) return df这套方案最大的好处是:同一份CSV不管跑多少次,清洗规则都是一样的,可复现、可审计。不会出现这次清洗和上次清洗结果不一致的情况。
3. 去重的关键决策:按什么子集、保留哪一行、怎么防范NaN坑
标准化做完之后,去重才有意义。但去重也不是简单调一个drop_duplicates()就完事,里面还牵扯到三个关键决策:按哪些列判断重复、保留哪一行、缺失值怎么处理。
3.1 整行去重还是按字段子集去重
drop_duplicates()默认是整行去重,也就是只有所有列的值完全一样才判定为重复。这个逻辑听上去没问题,但实际业务里很少用整行去重,因为不同系统导出的同一份数据,很可能存在“某个辅助字段不一致、但主键一致”的情况。
比如同一个订单,A系统里备注字段是空的,B系统里备注写了“已电话确认”,这时候两行在整行级别不重复,但在业务逻辑里它们就是同一个订单。所以更常见的做法是按业务主键或关键字段去重:
df = df.drop_duplicates(subset=['store_name', 'phone_clean'], keep='first')subset参数选哪些字段,取决于你对业务的理解。这个选择宁可偏严再放开,也不要一开始就把唯一性建立在“所有字段都一致”上。通常我会先按最核心的标识字段去重,比如身份证号、手机号、订单号,结果里如果发现有价值的记录被误删,再通过报告排查出来,调整subset。
3.2 先排序再去重:保留哪一行不能靠运气
drop_duplicates(keep='first')默认保留首次出现的行,keep='last'保留最后出现的行。问题在于:如果你的数据没有经过排序,所谓“首次出现”就是源文件里的顺序,而这个顺序在大多数情况下没有任何业务含义。
举个实际场景。会员表里有两条记录,会员ID相同,但一条是刚注册时的信息,一条是最近更新过的信息。如果直接drop_duplicates(subset=['member_id'], keep='last'),谁在文件里靠后谁就被保留;如果源文件顺序是随机的,那保留哪条就完全不可控了。
正确的姿势是先用一个有业务意义的字段排序,再去重:
df = (df .sort_values('updated_at', ascending=False) # 把最新更新的记录排前面 .drop_duplicates(subset=['member_id'], keep='first') # 保留最新的 .sort_index() # 恢复顺序,看心情决定要不要 )这样,去重结果就变得可解释了:我们保留的是每个会员ID中更新时间最新的一条。这个逻辑写进报告里,任何人都能看懂、能复现。
SQL里对应的做法是前面写过的:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY member_id ORDER BY updated_at DESC) AS rn FROM member_table ) t WHERE rn = 1;逻辑完全一致:先排序,再分组去重。
3.3 缺失值在去重中的隐性行为
这个坑我专门拎出来讲,因为太多人在实践里栽过。pandas的drop_duplicates()在处理NaN时有一个特殊行为:所有NaN被看作是同一个值。这意味着,如果某列有大量缺失,而这些行又恰好被纳入了去重子集,那它们会被批量判定为重复。
举个具体例子。按phone字段去重,表中有一千行是phone=NaN,那这一千行会先内部互相判重,最终只保留一行,剩下的九百九十九行全被删掉。如果这些缺失电话的行在业务上是不同的客户,那这就不是去重,而是误删了。
解决思路有三种:
- 在标准化阶段就把空值统一填充成一个明确的占位符,例如
'UNKNOWN',这样不会影响去重判断; - 将缺失值从去重判断中排除,先处理有值数据的去重,再单独处理缺值数据;
- 使用
subset时选择缺失率较低的字段,避免大量缺失列参与判重。
我通常选第一种,因为标准化阶段反正要做缺失值统一,干脆就统一成有业务含义的占位符,而不是让NaN这种隐式语义在后续操作里捣乱。
3.4 用行级哈希指纹做快速判重
除了字段级判重,还有一种场景需要做“整行镜像”检测:文件合并时同一行被完整地复制了两遍。这种行只要所有列的都相等就是重复,但逐列比较在列很多时挺麻烦。这时候可以入行级哈希指纹。
简单实现是用hashlib对整行拼接结果做MD5,给每一行打一个指纹,再基于指纹去重:
import hashlib def row_fingerprint(row): raw = '|'.join([str(v) for v in row]) return hashlib.md5(raw.encode('utf-8')).hexdigest() df['_fingerprint'] = df.apply(row_fingerprint, axis=1) df = df.drop_duplicates(subset=['_fingerprint'], keep='first')pandas也提供了更方便的工具:
from pandas.util import hash_pandas_object df['_hash'] = hash_pandas_object(df, index=False).astype(str)指纹列的好处是它把整行信息压缩成一个短字符串,去重判断非常快,而且指纹本身可以写进报告,作为检查依据。要注意的是,指纹计算前必须先完成字段标准化,否则同样的数据因为格式不同会生成不同指纹,等于白做。
4. 给清洗留证据:删除明细、汇总报告与文件指纹
很多人做完去重后直接to_csv输出干净文件就算完事。但如果后续业务方问一句“你到底删了哪些?删之前是多少行?”你拿不出证据,那就尴尬了。更现实的情况是,某条重要的记录被误删了,你需要在几分钟内定位到它是为什么被删的。所以,可检查的报告不是锦上添花,是清洗流程的必要环节。
4.1 删除明细报告:每一行被删的数据都要留底
删除明细的基本思想很简单:把判定为重复、且未被保留的那些行,单独导出一个CSV文件。文件里除了原始数据,建议再加上几个说明性字段,方便回溯:
| 字段 | 说明 |
|---|---|
_dup_group | 重复组编号,同一组重复记录共用一个编号 |
_is_kept | 该行是否被保留 |
_drop_reason | 删除原因,例如“按member_id判定重复,保留最新记录” |
_original_index | 原文件中的行号/索引,方便回到源文件定位 |
生成删除明细的做法是这样:
# 在标准化、排序后的全量数据上打上重复标记 df_all['_dup_flag'] = df_all.duplicated(subset=['member_id'], keep=False) # 去重保留下来的数据 df_kept = df_all.drop_duplicates(subset=['member_id'], keep='first') # 被删数据 = 参与重复的行中,不在保留集合里的行 removed_index = df_all.index.difference(df_kept.index) df_removed = df_all.loc[removed_index].copy() df_removed['_drop_reason'] = 'member_id重复,保留更新时间最新记录'这里用duplicated(subset=..., keep=False)先标出所有参与重复的行,再用保留集合和被删除集合做索引差,逻辑很清晰。最后把删除明细写出去:
df_removed.to_csv('output/removed_records.csv', index=False, encoding='utf-8-sig')有了这个文件,任何一行数据被删都能查得到:它原来在第几行,因为跟谁重复,被谁替代了。这比光给一个干净文件要可靠得多。
4.2 清洗前后指标对比:用数字证明清洗有效
除了删除明细,我还会生成一份“清洗前后指标对比”,把核心数字汇总出来。常用的指标包括:
- 原始行数、标准化后行数、去重后行数;
- 被删除的行数;
- 关键字段的唯一值数量,比如会员ID去重前多少、去重后多少;
- 每个字段的缺失值数量;
- 重复率,即被删行数占原始行数的比例。
这些指标可以整理成一个简单的文本或JSON报告:
summary = { 'source_file': 'raw_channels.csv', 'total_rows_before': len(df_raw), 'total_rows_after_standardize': len(df_std), 'total_rows_after_dedup': len(df_kept), 'removed_rows': len(df_removed), 'duplicate_rate': round(len(df_removed) / len(df_raw), 4), 'unique_member_id_before': df_raw['member_id'].nunique(), 'unique_member_id_after': df_kept['member_id'].nunique(), } import json with open('output/summary.json', 'w', encoding='utf-8') as f: json.dump(summary, f, ensure_ascii=False, indent=2)这份报告的价值在于:你不需要展开细节,只看汇总数字就能判断这次清洗是否正常。比如重复率突然从5%飙到40%,多半是标准化规则太激进或者subset选错了字段,这时就能回到明细里去排查。
4.3 文件MD5/SHA256指纹:让报告和文件能对上
“可检查”的最高级别,是给输出的每个文件都生成一个唯一的指纹,防止后续文件在传输、下载或转存过程中被无意改动。这就是很多人提到的“CSV文件怎么做MD5校验”。
文件级别校验很简单,直接对文件计算MD5或SHA256,拿Linux/Mac终端来说:
md5sum output/clean_data.csv sha256sum output/removed_records.csvWindows PowerShell也可以用:
Get-FileHash -Algorithm SHA256 .\output\clean_data.csv把得到的哈希指纹写进报告文件,以后任何人拿到这批CSV,都可以重新计算哈希值,和报告里的指纹比对,确认文件是否被改动过。这有点类似给文件做了个“身份证号”,把整个清洗工作的交付状态固化下来。
对于行级别的MD5校验,之前在第三章也提过,就是用哈希指纹给每一行做唯一标记。两种指纹的应用场景不同,文件级指纹用于验证交付文件的完整性,行级指纹用于行级别的判重与追踪,可以搭配使用。
5. 一次完整的本地CSV清洗:带注释的实战流程
前面讲了理论和要点,这一章我放一个可以直接照着改的完整示例。假设现在有两份渠道商CSV,需要合并、清洗、去重,并输出干净文件、删除明细和汇总报告。
5.1 场景说明与目录结构
先看目录结构:
project/ ├── raw/ │ ├── channel_a.csv │ └── channel_b.csv ├── output/ │ ├── clean_data.csv │ ├── removed_records.csv │ └── summary.json └── clean_csv.pychannel_a.csv和channel_b.csv字段基本一致,但字段格式不完全相同,可能有编码差异和重复记录。目标是把两份文件合并成一份干净数据,按store_id去重,保留最新更新的一条。
5.2 分步代码与执行逻辑
完整代码如下:
import pandas as pd import hashlib import json # ---------- 1. 读取与合并 ---------- df_a = pd.read_csv('raw/channel_a.csv', dtype=str, encoding='utf-8') df_b = pd.read_csv('raw/channel_b.csv', dtype=str, encoding='utf-8') # 如果列名不一致,先统一字段名 df_a.columns = [str(c).strip().lower().replace(' ', '_') for c in df_a.columns] df_b.columns = [str(c).strip().lower().replace(' ', '_') for c in df_b.columns] df = pd.concat([df_a, df_b], ignore_index=True) print(f'合并后总行数: {len(df)}') # ---------- 2. 标准化 ---------- # 字符串列统一去掉首尾空格和全角空格,统一中英文括号 str_cols = df.select_dtypes(include=['object']).columns for col in str_cols: if col not in ['update_time', 'store_id']: df[col] = (df[col] .astype(str) .str.strip() .str.replace('\u3000', '', regex=False) .str.replace('(', '(', regex=False) .str.replace(')', ')', regex=False)) # 门店ID如果存在非数字字符,一律去除 if 'store_id' in df.columns: df['store_id'] = df['store_id'].astype(str).str.replace(r'\D', '', regex=True) # 日期字段统一为 YYYY-MM-DD if 'update_time' in df.columns: df['update_time'] = pd.to_datetime(df['update_time'], errors='coerce').dt.strftime('%Y-%m-%d') # 空值占位符统一 df = df.replace(['', 'NULL', 'NA', 'N/A', 'None', 'null', '-', 'nan'], pd.NA) df = df.fillna('') # ---------- 3. 生成行级指纹(可选,用于快速核对) ---------- def row_fingerprint(row): raw = '|'.join([str(v) for v in row]) return hashlib.md5(raw.encode('utf-8')).hexdigest() df['_row_fingerprint'] = df.apply(row_fingerprint, axis=1) # ---------- 4. 去重:先按更新时间排序,再按门店ID去重 ---------- df_sorted = df.sort_values('update_time', ascending=False, na_position='last') df_kept = df_sorted.drop_duplicates(subset=['store_id'], keep='first') # 标记所有参与重复的行 df['_dup_flag'] = df.duplicated(subset=['store_id'], keep=False) removed_index = df.index.difference(df_kept.index) df_removed = df.loc[removed_index].copy() df_removed['_drop_reason'] = 'store_id重复,保留update_time最新记录' print(f'去重后行数: {len(df_kept)},删除行数: {len(df_removed)}') # ---------- 5. 输出文件 ---------- df_kept.drop(columns=['_dup_flag']).to_csv('output/clean_data.csv', index=False, encoding='utf-8-sig') df_removed.drop(columns=['_dup_flag']).to_csv('output/removed_records.csv', index=False, encoding='utf-8-sig') # ---------- 6. 汇总报告 ---------- summary = { 'source_files': ['channel_a.csv', 'channel_b.csv'], 'total_rows_after_merge': len(df), 'total_rows_after_dedup': len(df_kept), 'removed_rows': len(df_removed), 'duplicate_rate': round(len(df_removed) / len(df), 4), 'unique_store_id_before_dedup': df['store_id'].nunique(), 'unique_store_id_after_dedup': df_kept['store_id'].nunique(), } with open('output/summary.json', 'w', encoding='utf-8') as f: json.dump(summary, f, ensure_ascii=False, indent=2) print('清洗完成,结果保存在 output/ 目录下')这段代码的逻辑很直白:读文件、合并、标准化、打指纹、排序去重、输出明细和报告。你可以根据实际字段名调整subset和排序字段,框架不需要动。
5.3 跑完之后的检查清单
代码能跑通只是第一步,我每次清洗完还会做下面这套检查,确保结果真的可信:
- 看
summary.json里的删除行数和重复率,如果异常偏高,先怀疑标准化阶段是不是把大量有效值误统一成了同一个占位符; - 打开
removed_records.csv,随机抽几行,对照_drop_reason确认删除逻辑正确; - 用文件哈希指纹再核对一遍输出文件的完整性,确认没有写入时出现异常;
- 如果业务方需要,可以把
clean_data.csv和removed_records.csv一起交给他们,而不是只给一个干净文件。
这套检查动作做完,我才会放心地把数据交付出去。
最后分享一个我自己的习惯:清洗脚本本身也要做版本管理,每次跑批都在脚本头部写清楚清洗规则和日期,输出报告的文件名加上时间戳,比如summary_20250411.json。这样一个月后再有人问“这份数据是怎么洗出来的”,你翻出当天的脚本、报告和指纹,几分钟就能完整还原整个过程。清洗这件事,最怕的不是数据脏,而是脏完之后没人说得清到底做了什么。把过程留给别人查,也留给未来的自己查,这才是“可检查的报告”真正的意义。