Download twlat/normalize.py from JacobLinCool/Twinity-1: direct link, hf CLI and curl.
- Browser
- Download file 7.69 kB
-
https://huggingface.co/JacobLinCool/Twinity-1/resolve/main/twlat/normalize.py
- Command line
-
hf download hf://JacobLinCool/Twinity-1/twlat/normalize.py
-
curl -L -o normalize.py https://huggingface.co/JacobLinCool/Twinity-1/resolve/main/twlat/normalize.py
7.69 kB
| """Safe normalization:只做**無歧義**的確定性轉換(TWLAT-R 的 deterministic layer)。 | |
| 與 V1 的差別,以及為什麼要換掉 zhtw-mcp 的 fixer: | |
| V1 直接用 `zhtw-mcp convert` 當 deterministic layer。實測它會製造三類錯誤, | |
| 而且模型無權修正(因為錯誤發生在模型看到文字之前): | |
| 1. 引號配對會刪除字元(『稲亭物怪録』→ 稲亭物怪録) | |
| 2. casing 規則改動 inline code 內的識別字(`typescript` → `TypeScript`) | |
| 3. 詞表做了語境相依的決策(商调制度 → 商調製度、十姑娘 → 十姑孃) | |
| 本模組只做「任何語境下都對」的轉換,其餘一律交給模型當 proposal: | |
| - 引號正規化(自行實作,不刪字元) | |
| - **單候選**簡→繁字元轉換(一簡多繁一律不碰,交給模型) | |
| - CJK 相鄰的半形→全形標點 | |
| - CJK 與拉丁/數字之間補空格 | |
| zhtw-mcp 仍然是**字典來源**(1,853 條規則的語意條件),這是它真正的價值; | |
| 但它的 fixer 不再位於資料路徑上。 | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import pathlib | |
| import regex | |
| from twlat import quotes | |
| from twlat.paths import data_file | |
| HAN = regex.compile(r"\p{Han}") | |
| CJK = regex.compile(r"[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]") | |
| LATIN_NUM = regex.compile(r"[A-Za-z0-9]") | |
| # 一簡多繁的預設值改以**臺灣語料頻率**排序,取代 OpenCC 的任意順序。 | |
| # 實測 OpenCC 第一候選在 36/239 個字上是錯的: | |
| # 里→裏(語料 12,333 vs 里 274,290)、吃→喫(326 vs 68,673)、 | |
| # 朴→樸(2,401 vs 6,223,害「朴正熙」變「樸正熙」)、咸→鹹(咸豐帝變鹹豐帝) | |
| # 由 tools/rank_variants.py 產生。 | |
| _RANK_P = data_file("dict/variant_rank.json") | |
| VARIANT_ORDER: dict[str, list[str]] = ( | |
| json.loads(_RANK_P.read_text(encoding="utf-8"))["order"] | |
| if _RANK_P.exists() else {}) | |
| # D-12 政策例外:台/臺 沿用 zhtw-mcp strict 的規範,不依頻率。 | |
| POLICY_OVERRIDE = {"台": "臺"} | |
| def _load_s2t() -> dict[str, str]: | |
| """簡→繁字元表。 | |
| 一簡多繁**也要轉**,取 OpenCC 的第一候選(最常見)——因為留著簡體字 | |
| 比選錯繁體更糟(zhtw-mcp 就是選擇留簡體,實測「復置」被輸出成「复置」, | |
| 比純 OpenCC 還差)。真正的選擇留給 proposal:該位置會被提出候選集, | |
| 由模型依語境決定要不要改成別的候選。 | |
| """ | |
| m: dict[str, str] = {} | |
| p = data_file("dict/rules/st_characters.tsv") | |
| for line in p.read_text(encoding="utf-8").splitlines(): | |
| if not line.strip(): | |
| continue | |
| parts = line.split("\t") | |
| if len(parts) != 2: | |
| continue | |
| src, cands = parts[0], parts[1].split() | |
| if len(src) != 1 or not cands or len(cands[0]) != 1: | |
| continue | |
| if src == cands[0]: | |
| continue | |
| m[src] = cands[0] | |
| # 以語料頻率覆寫一簡多繁的預設值 | |
| for s, order in VARIANT_ORDER.items(): | |
| if len(s) == 1 and order and s in m: | |
| m[s] = order[0] | |
| m.update(POLICY_OVERRIDE) | |
| return m | |
| S2T_CHAR = _load_s2t() | |
| def _load_twv() -> dict[str, str]: | |
| """OpenCC TWVariants 39 條臺標變體(裏→裡、着→著、喫→吃)。 | |
| V3 專用(do_twv=True):確保 base 文本與 cloze 預訓練語料都是臺標形。 | |
| V1/V2 預設關閉——它們的模型與已發表數字是在無此正規化下訓練/評測的, | |
| 改變共用預設會靜默移動凍結的 baseline。 | |
| """ | |
| m: dict[str, str] = {} | |
| p = data_file("dict/rules/tw_variants.tsv") | |
| for line in p.read_text(encoding="utf-8").splitlines(): | |
| parts = line.split("\t") | |
| if len(parts) == 2 and len(parts[0]) == 1: | |
| m[parts[0]] = parts[1].split()[0] | |
| return m | |
| TWV_CHAR = _load_twv() | |
| # S2T 輸出值一併臺標化(st_characters 第一候選可能是 裏 這類非臺標形) | |
| S2T_TW = {k: "".join(TWV_CHAR.get(c, c) for c in v) for k, v in S2T_CHAR.items()} | |
| # V3.1:**常用**的自候選歧義字不做預轉換——選擇完全交給 lattice+模型。 | |
| # V3.0 的教訓(bench 錯誤解剖): | |
| # 1. POLICY_OVERRIDE 台→臺 與真實語料寫法衝突,獨佔 40% 的 keep 錯誤; | |
| # 2. 頻率預設會翻轉正確輸入(核准→核準),製造本不存在的 change 任務。 | |
| # 判準:該字是自己的候選之一(st_characters 行含自身,如 里→裏 里) | |
| # **且**臺灣語料頻率 ≥ 500(排除 广/厂 這類 OpenCC 視為罕見繁體、 | |
| # 實際上留著就是簡體殘留的字——广 頻率 30 vs 台 399,155)。 | |
| _VR_FREQ: dict[str, int] = json.loads( | |
| data_file("dict/variant_rank.json").read_text(encoding="utf-8"))["freq"] | |
| _SELF_OK: set[str] = set() | |
| for _line in data_file("dict/rules/st_characters.tsv").read_text( | |
| encoding="utf-8").splitlines(): | |
| _p = _line.split("\t") | |
| if len(_p) == 2 and _p[0] in _p[1].split() \ | |
| and _VR_FREQ.get(_p[0], 0) >= 500: | |
| _SELF_OK.add(_p[0]) | |
| S2T_TW_V3 = {k: v for k, v in S2T_TW.items() if k not in _SELF_OK} | |
| HALF_FULL = {",": ",", ";": ";", ":": ":", "!": "!", "?": "?", | |
| "(": "(", ")": ")"} | |
| def _punct(text: str) -> str: | |
| """CJK 相鄰的半形標點轉全形。句點另外處理(避免動到小數與副檔名)。""" | |
| out = list(text) | |
| n = len(text) | |
| for i, ch in enumerate(text): | |
| if ch not in HALF_FULL and ch != ".": | |
| continue | |
| prev = text[i - 1] if i else "" | |
| nxt = text[i + 1] if i + 1 < n else "" | |
| if ch == ".": | |
| # 只在前一字是 CJK 且下一字非數字時轉句號 | |
| if CJK.match(prev or " ") and not (nxt and nxt.isdigit()): | |
| out[i] = "。" | |
| continue | |
| if CJK.match(prev or " ") or CJK.match(nxt or " "): | |
| out[i] = HALF_FULL[ch] | |
| # 轉全形後要吃掉緊跟的空格,否則會留下「, 」這種贅格 | |
| if nxt == " ": | |
| out[i + 1] = "" | |
| return "".join(out) | |
| def _spacing(text: str) -> str: | |
| """CJK 與拉丁/數字之間補一個半形空格;已有空白則不重複。""" | |
| out = [] | |
| for i, ch in enumerate(text): | |
| if i: | |
| a, b = text[i - 1], ch | |
| need = (CJK.match(a) and LATIN_NUM.match(b)) or \ | |
| (LATIN_NUM.match(a) and CJK.match(b)) | |
| if need: | |
| out.append(" ") | |
| out.append(ch) | |
| return "".join(out) | |
| def safe_normalize(text: str, do_quotes: bool = True, do_s2t: bool = True, | |
| do_punct: bool = True, do_spacing: bool = True, | |
| do_twv: bool = False) -> str: | |
| """只套用無歧義轉換。各步驟可關閉以做消融。do_twv 見 _load_twv 註解。""" | |
| t = text | |
| if do_quotes: | |
| t = quotes.normalize(t) | |
| if do_s2t: | |
| s2t = S2T_TW_V3 if do_twv else S2T_CHAR | |
| t = "".join(s2t.get(c, c) for c in t) | |
| if do_twv: | |
| t = "".join(TWV_CHAR.get(c, c) for c in t) | |
| if do_punct: | |
| t = _punct(t) | |
| if do_spacing: | |
| t = _spacing(t) | |
| return t | |
| def stats() -> dict: | |
| return {"unambiguous_s2t_chars": len(S2T_CHAR)} | |
| if __name__ == "__main__": | |
| print(stats()) | |
| for s in ["这个程序有bug,请在服务器上部署。", | |
| "现行公务人员指名商调制度", | |
| "十姑娘的香港法律顾问", | |
| "他问:“老师,‘有条不紊’的‘紊’是什么意思?”", | |
| "版本 v1.2.3 已发布,请访问 https://a.b/c 。"]: | |
| print(f"\nIN : {s}\nOUT: {safe_normalize(s)}") | |