解析器 — 原子化
修复的第一步。在字段成为原子化之前,不要进行标准化。 对包含“Na Budíně 854”的 ADDR_STREET 字段进行标准化是无用的——地址登记册将街道和门牌号分开存储。
流水线:审计 → dq-parser → dq-standardizator → dq-adresar → dq-imputator → dq-deduplikator。
规则
- 原始数据不删除。 解析后的输出进入新列(
_STD、_NUM1、_NUM2)。 原始数据是前后对比的证据,也是在解析器出错时的安全网。 - 确定性,而非启发式。 一个在3%的情况下会猜错的解析器会制造静默损坏。如果对某个规则不确定,请保留NULL并计数剩余的数量。 这适用于具有已知模式的结构化字段——地址、姓名、日期。对于没有模式的自由文本,统计实体识别(NER, spaCy)是合法工具,但其输出是估计值:它需要附带置信度分数和标志,并且在未经审查的情况下不能放入生产列。
- 衡量解析程度 — 成功分解的行数。这是报告中的一个指标。
- 在任何后续操作之前进行解析:标准化、插补和去重都依赖于原子化字段。
地址
最常见的缺陷:专用的门牌号列100%为空,门牌号与街道名称混杂在一起。在源数据中看起来像 Na Budíně 854、Nádražní 1211/25a、Bezručova 12/3。
分解为三个组成部分:街道名称、描述性门牌号、方位门牌号(可能带有字母):
-- MySQL 8: REGEXP_SUBSTR / REGEXP_REPLACE
UPDATE PARTY_ADDRESS SET
ADDR_NUM1_STD = REGEXP_SUBSTR(ADDR_STREET, '[0-9]+(?=(/[0-9]+[a-zA-Z]?)?[[:space:]]*$)'),
ADDR_NUM2_STD = REGEXP_SUBSTR(ADDR_STREET, '(?<=/)[0-9]+[a-zA-Z]?[[:space:]]*$'),
ADDR_STREET_STD = TRIM(REGEXP_REPLACE(ADDR_STREET, '[0-9]+(/[0-9]+[a-zA-Z]?)?[[:space:]]*$', ''));
Python 等效实现,当以 pandas 流水线运行时:
num = raw.str.extract(r'(\d+)(?:/\d+\w?)?\s*$', expand=False) # 描述性门牌号
street = raw.str.replace(r'\d+(/\d+\w?)?\s*$', '', regex=True).str.strip()
注意数字的顺序:在捷克共和国,通常写为 描述性/方位性,但惯例并不总是遵循。当解析器只找到一个数字时,把它当作描述性门牌号,方位门牌号保留为 NULL——不要猜测。
城市字段中的复合值
Praha 5 - Smíchov,České Budějovice - České Budějovice 5 — 操作员将整个地址层次结构(城市 + 城区 + 区号)写入了单个字段。使得与登记册的匹配变得不可能。
-- 第一个连字符前的段 = 城市,其余部分 = 城区
SELECT ADDR_CITY,
TRIM(SUBSTRING_INDEX(ADDR_CITY, '-', 1)) obec,
NULLIF(TRIM(SUBSTRING(ADDR_CITY, LOCATE('-',ADDR_CITY)+1)),'') mcast
FROM PARTY_ADDRESS WHERE ADDR_CITY LIKE '%-%';
在开始解析之前检测候选:
SELECT ADDR_CITY, COUNT(*) cnt FROM PARTY_ADDRESS
WHERE ADDR_CITY REGEXP '[0-9]' OR ADDR_CITY LIKE '%-%' OR LENGTH(ADDR_CITY) > 50
GROUP BY 1 ORDER BY cnt DESC LIMIT 20;
注意:城市名称中的连字符有时是合法的(Frýdek-Místek,Brandýs nad Labem-Stará Boleslav)。因此,分解要对照城市登记册进行验证,而非盲目进行——如果分解后的城市不在登记册中,则返回原始值。
邮政信箱和其他超出范围的格式
邮政信箱、邮局信箱、“公司投递地址”——形式上有效的地址,但建筑物登记册不识别。不要强行解析它们,用专门的标志标记它们,并从匹配率指标中排除(否则你会将非错误的东西报告为错误)。
姓名
间隔空格
D A V I D,Ž A N N A — 每个字母之间用空格分隔。当所有标记都是单字母,或者大多数是单字母时(其余部分通常是连字符或首字母),可以确定性地合并:
toks = s.split()
if len(toks) > 1 and all(len(t) == 1 for t in toks):
s = "".join(toks)
elif len(toks) > 1 and sum(len(t) == 1 for t in toks) >= max(2, len(toks) - 1):
s = "".join(toks)
切勿普遍合并标记——Anna Marie 是两个名字,不是 AnnaMarie。
多部分名字
名+中间名在单个字段中。按空格分割,将第二个标记单独存储;丢弃单字母标记(首字母),否则你会生成无意义的中间名:
UPDATE PART_PARTY
SET PARTY_FNAME_STD = <proper>(SUBSTRING_INDEX(PARTY_FNAME,' ',1)),
PARTY_MNAME_STD = NULLIF(<proper>(SUBSTRING_INDEX(SUBSTRING_INDEX(PARTY_FNAME,' ',2),' ',-1)),
<proper>(SUBSTRING_INDEX(PARTY_FNAME,' ',1)))
WHERE PARTY_TYPE='P' AND LENGTH(SUBSTRING_INDEX(PARTY_FNAME,' ',2)) > 1;
记录在名字字段中的头衔
Ing. Jan Novák 在 PARTY_NAME 中。根据第一个标记与头衔字典的匹配来检测,而不是根据句点——J. Novák 是首字母,不是头衔。
字典是头衔编码表(REF_*_TITBEF 用于名字前的头衔,TITAFT 用于名字后的头衔),并且要进行模糊匹配,因为数据中可能同时出现 MuDr,MUDr. 和 Mudr。解析器仅分离出头衔;转换为规范形式是 dq-standardizator 的工作。
编码错误
两种不同的事物,两种不同的解决方案:
| 错误 | 示例 | 解决方案 |
|---|---|---|
| 字符系统性替换 | ĄUDOVÍT(应为 ĽUDOVÍT) | 确定性替换,如果模式是明确的——例如,单词开头的 Ą 总是误写,因为在波兰语中它永远不会出现在那里 |
| 丢失的字母 | BE0ŠOVÁ(应为 BENEŠOVÁ) | 不要在解析器中修复。 使用 _CORRUPT 标志标记,并让恢复工作由 dq-standardizator(名字登记册)或 dq-deduplikator(集群中的干净副本)完成 |
s = re.sub(r"(^|(?<=\s))Ą", "Ľ", s) # 明确的、确定性的
corrupt = bool(re.search(r"[0-9]", s)) # 名字中的数字 = 损坏,在其他地方处理
这种划分是有意的:解析器只做确定的事。重建丢失的字母是一种估计,属于可以对照某些东西进行验证的环节。
以文本形式存储的日期
在解析之前,验证格式和日/月:
SELECT SUBSTRING(<col>,1,2) prvni, SUBSTRING(<col>,4,2) druhy, COUNT(*)
FROM <t> GROUP BY 1,2 ORDER BY 3 DESC LIMIT 20;
当第一个位置从未超过12而第二个位置有时超过时,顺序是 MM.DD;当两者都超过12时,格式是混合的,必须根据第二个源在每个行级别上决定。盲目的 STR_TO_DATE 将不会捕获静默交换,并会将其散布到整个表。
解析器的输出
- 具有原子值的新
_STD列,原始数据保持不变。 - 每个组成部分的解析程度(
结果不为空的行百分比)。 - 错误表包含解析器未分解的行及其原因——关于记录未通过的原因的审计跟踪。用于人工审查或“这超出了范围”的决策的输入。