数据清洗与核对
默认工具:本地文件使用 DuckDB 2.0 SQL 或 Polars 2.x 惰性 API(scan_csv、scan_parquet)。仅在库强制要求时使用 Pandas。Python 通过 uv 运行,使用 Ruff 进行代码检查。在依赖 Polars 2.0 或 DuckDB 2.0 中任何已更改的 API 详情前,请对照当前文档进行验证(参见 references/landscape-2026-11.md)。
绝不静默修复
每一次强制转换、丢弃、填充默认值或合并操作都必须被计数并报告。一个清洗步骤应记录为 (输入行数, 输出行数, 变更行数, 原因)。转换失败应变为 NULL 并计数,绝不能丢弃行或使用猜测值。保持原始输入不动;将清洗后的输出写入新路径。如果某个步骤的计数令人意外,请停止并报告,不要继续。
操作流程
- 探查(只读)。2. 确定键、粒度和类型。3. 通过由多个小型计数步骤组成的管道进行清洗。4. 如果存在两个数据源,则进行核对。5. 输出异常结果以及包含所有计数的运行摘要。
1. 接触数据前先探查
首先将所有列作为文本读取(DuckDB 中使用 all_varchar=true,Polars 中使用 infer_schema=False),因为基于样本的类型推断会静默地将 00123 转换为 123,并将混合列转换为第一行的类型。
然后对每一列计算:行数、空值/空字符串比率、唯一值数量、最小/最大值(作为文本和解析后)、最短/最长长度,以及匹配某种类型模式(整数、小数、ISO 日期、其他)的值的比例。
DuckDB 的 SUMMARIZE 通过一次调用即可完成大部分工作。
记录清洗前的信息:总行数、不同候选键的数量、每个键的最大行数,以及文件头的确切内容(尾随空格、BOM、重复名称)。候选键仅在标准化后唯一值数量等于行数时才是真正的键;请测试它,不要假设。
检查:整行重复、文件头在文件中重复出现、数据中包含的页脚/总计行、哨兵值(“N/A”、“-”、“0000-00-00”、9999-12-31)、混合编码,以及实际上是标识符的数字列。
2. 类型与解析
- ID、邮编/邮政编码、账号和电话号码始终是字符串。仅在宽度有文档记录时,才将补零宽度存储为规则(
lpad填充到已知宽度);否则保持原样读取。 - 日期:对每列使用明确的格式进行解析(
try_strptime/str.to_date(format, strict=False))。绝不让引擎猜测日月顺序。对于没有文档记录顺序的模糊值(03/04/2026)属于异常,而非随意选择。明确声明时区处理。 - 货币:解析为整数最小单位或
DECIMAL(p,s),绝不用浮点数。根据区域规则去除货币符号和千位分隔符:1.234,56和1,234.56属于不同区域,因此需要按来源决定,并断言每个值都匹配所选模式。括号和尾随减号表示负数。按行记录货币;不要跨货币求和。 - 布尔值和枚举:从显式映射表中转换;未映射的值归入异常。
- 每次强制转换后,计算“转换前非空值数量”减去“转换后非空值数量”。该数字即为转换失败计数,将包含在报告中。
3. 标准化
应用于派生的匹配键,而不是原始列,以便保留原始值:
Unicode NFKC 标准化、去除首尾空格、压缩内部空白、转换为小写、去除零宽空格和不间断空格。
NFKC 是有损的(连字、全角字符、上标),这正适合匹配场景,但不适合显示。
对于标识符,还要去除分隔符(-、/、空格),并明确处理 O/0 和 I/1。
DuckDB 核心 SQL 不直接支持 NFKC;请在 Polars/Python 中处理,或注册一个 UDF(请先验证当前支持情况)。
4. 去重
- 完全重复:丢弃并计数;保留幸存行的原始行号。
- 按键重复:首先判断它们是真正的重复记录还是不同的事件(相同的发票号但金额不同,这是冲突,而非重复)。
- 幸存者选择必须确定并声明:
ROW_NUMBER() OVER (PARTITION BY key ORDER BY updated_at DESC NULLS LAST, completeness DESC, source_row DESC) = 1(DuckDB 使用QUALIFY)。 始终以唯一决胜项(如来源行号)结束 ORDER BY,因为平局情况下会通过任意扫描顺序解决。 在 Polars 中使用sort(...)然后unique(subset, keep="first", maintain_order=True)。 - 模糊去重只是生成候选项,绝不能自动合并:基于一个廉价键(标准化邮政编码、前 3 个字母、年份)进行分块,在块内评分,仅在阈值较高且有一个精确的辅助字段一致的情况下才自动接受,并将中间范围发给人工处理列表。报告阈值和计数。
5. 核对审计
源头数据 = A,衍生数据 = B(例如系统导出文件与 OCR 扫描文档)。在检查是提取问题还是键问题之前,绝不说“B 是错误的”。
- 首先检查控制总计。 A 和 B 中的行数以及每个货币/数量列的总和,加上不同键的数量。不匹配告知您是应该期望缺少行、多余行还是金额漂移。总计匹配而行数不同意味着错误相互抵消;继续检查。
- 键匹配。 在两侧构建相同的标准化键(第 3 节)。首先进行精确匹配,然后按严格程度递减的显式轮次进行(例如,仅键,然后键加日期窗口),每个匹配都标记产生它的轮次。切勿混合未经标记的轮次。
- 双向反连接。 A 中但不包含在 B 中的(从衍生数据中缺失)和 B 中但不包含在 A 中的(衍生数据中意外出现的,通常是 OCR 键拼写错误或重复)。报告两者;只读取一个方向会隐藏一半的错误。在将某行标记为缺失之前,检查其键是否与任何一侧的重复项冲突(多对多连接会夸大总计)。
- 对匹配对进行字段比较。 在相同标准化处理后逐字段比较。
货币容差:以最小单位比较,
abs(a - b) <= tol,其中 tol 是提前声明的(分类账为 0;仅当不同数据源舍入方式不同时,每行允许 1 个最小单位的舍入容差,小额金额绝不允许使用百分比)。 日期:精确匹配,或声明的天数窗口。 文本:标准化后相等,然后给出一个相似度评分,报告为分数,而非通过/不通过。 - 分类,而不仅仅是差异。 每个异常都有一个原因代码:
MISSING_IN_B、EXTRA_IN_B、DUP_KEY_A、DUP_KEY_B、AMOUNT_MISMATCH、FIELD_MISMATCH、UNPARSEABLE_A、UNPARSEABLE_B、AMBIGUOUS_MATCH。不可解析的行是异常,绝不能静默地从总计中排除。 - 闭环。 最终的恒等式必须成立:
|A| = 匹配项 + 缺失于B项 + A侧多余重复项 + 不可解析A项(以及 B 的等价式)。如果无法平衡,则审计未完成。
用于多轮匹配、反连接、容差和平衡检查的 SQL 模式位于 references/reconciliation-recipes.md 中。
异常报告
每行一个异常,无需工具即可阅读:
reason_code、key、A 源文件和行号、B 源文件和行号(以及扫描文档的页码)、field、a_value、b_value、delta、suggested_action。
按金额影响降序排序,然后按原因排序。
在顶部放置运行摘要:带有行数和校验和的输入文件、每个清洗步骤及其计数、双方的控制总计、平衡恒等式、使用的容差。
为机器写入 CSV,为人类编写简短的 markdown 或 xlsx 视图;不要将数千行数据直接转储到聊天中,应按原因汇总计数并显示前几条。
安全护栏
- 断言,不要目测:连接后的行数、去重后的键唯一性、货币列不能是浮点数。
- 在非唯一键上进行左连接会成倍增加行数;每次连接前后都要检查计数。
- 不要覆盖 A。对源头数据的更正属于人工决策,需列在报告中。
- 数据库的密钥从
/run/secrets/<name>读取,而不是环境变量或命令行。 - 一旦规则稳定,值得添加声明式检查(soda-core、great_expectations);但上述分析和计数是优先步骤。
实际应用中的经验教训会累积在 references/gotchas.md 中。