Twinity-1 / twlat /normalize.py
JacobLinCool's picture
Twinity-1: weights, compiled dictionary, inference code
6cc3500 verified
Raw
History Blame Contribute Delete
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)}")