解析器 — 原子化
修復的第一個步驟。在未原子化之前,切勿進行標準化。 對包含「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:]]*$', ''));
在使用 pandas 管線時的 Python 等效方法:
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 — 不要猜測。
行政區欄位中的複合值
台北市中正區、新竹市東區 — 操作員將整個行政區層級(直轄市/縣市 + 區/鄉鎮市)寫入單一欄位。使得無法與官方登記冊進行比對。
-- 第一個區段(若存在) = 直轄市/縣市,第二個區段 = 區/鄉鎮市
SELECT ADDR_CITY,
TRIM(SUBSTRING_INDEX(ADDR_CITY, '-', 1)) city,
NULLIF(TRIM(SUBSTRING(ADDR_CITY, LOCATE('-',ADDR_CITY)+1)),'') district
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 而第二個位置超過 12 時,順序為 MM.DD;當兩個位置都超過 12 時,格式是混合的,必須根據第二個來源逐行判斷。盲目的 STR_TO_DATE 無法捕捉到這種無聲的互換。
解析器輸出
- 新的
_STD欄位包含原子化值,原始欄位保持不變。 - 每個部分的解析完成率(
具有非空結果的行數百分比)。 - 錯誤表 列出解析器無法拆解的行,以及每行的原因 — 提供記錄為何某筆記錄未通過的稽核軌跡。這是人工審查或決定「此資料超出範圍」的輸入。