| """Conversion lattice:V3 的核心資料結構。 |
| |
| 對 safe_normalize 後的文本,用 lattice lexicon(dict/lattice_lexicon.json) |
| 建出「所有字典允許的改寫」構成的圖: |
| |
| 節點 = 字元位置 |
| 邊 = (span, confusion group),group 的每個成員是一個候選(含 keep) |
| |
| 字典知識的分工(PI 指示的問題拆解): |
| - **確定性可判的,lattice 直接判**:exceptions 例外詞(函式庫 內不得改 函式)、 |
| positional_clues(好|消息 不觸發 消息→訊息)、詞界穿越(商調|制度 的 調製 邊) |
| ——這些命中即砍邊/砍候選,模型看不到也不需要看。 |
| - **語境相依的,交給模型**:剩下的每條邊帶 64 維字典特徵 |
| (領域 one-hot、規則型別、正反 clue 命中、語料頻率、editorial confidence…), |
| 模型只回答「這個語境下哪個成員成立」。 |
| |
| 重疊的邊一律保留,交給 Viterbi 全域解碼(src/twlat/decoder.py)。 |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import math |
| import pathlib |
| from dataclasses import dataclass, field |
|
|
| import ahocorasick |
| import numpy as np |
|
|
| from twlat.paths import data_file |
|
|
| LEXICON_PATH = data_file("dict/lattice_lexicon.json") |
|
|
| |
| N_DOMAINS = 36 |
| RULE_TYPES = ["cross_strait", "variant_char", "tw_phrase", "confusable", |
| "ai_filler", "translationese", "variant", "political_coloring", |
| "typo", "other"] |
| FEAT_DIM = 64 |
| CLUE_WINDOW = 40 |
| CN_ONLY_RATIO = 0.35 |
|
|
| |
| TAU_GROUP = {"variant_char": "variant", "variant": "variant", |
| "cross_strait": "lexical", "confusable": "lexical", |
| "tw_phrase": "lexical", "typo": "lexical", |
| "ai_filler": "style", "translationese": "style", |
| "political_coloring": "style"} |
|
|
|
|
| @dataclass |
| class Edge: |
| start: int |
| end: int |
| gid: int |
| obs_ix: int |
| cand_kill: list[bool] |
| word_crossing: bool = False |
| word_contained: bool = False |
|
|
|
|
| @dataclass |
| class Lattice: |
| text: str |
| edges: list[Edge] = field(default_factory=list) |
|
|
|
|
| class LatticeBuilder: |
| def __init__(self, lexicon_path: pathlib.Path = LEXICON_PATH): |
| lex = json.loads(pathlib.Path(lexicon_path).read_text(encoding="utf-8")) |
| self.version: str = lex["version"] |
| self.strings: list[str] = lex["strings"] |
| self.groups: list[dict] = lex["groups"] |
| self.form2group: dict[str, int] = lex["form2group"] |
| self.rules: list[dict] = lex["rules"] |
| self.pairs: dict[tuple[str, str], int] = { |
| tuple(k.split("\t")): v for k, v in lex["pairs"].items()} |
| self.freq: dict[str, int] = lex["freq"] |
| self.exceptions: dict[str, list[int]] = lex["exceptions"] |
|
|
| self._a_sites = ahocorasick.Automaton() |
| for f in self.form2group: |
| self._a_sites.add_word(f, f) |
| self._a_sites.make_automaton() |
|
|
| self._a_exc = ahocorasick.Automaton() |
| for s in self.exceptions: |
| self._a_exc.add_word(s, s) |
| self._a_exc.make_automaton() |
|
|
| self._a_words = ahocorasick.Automaton() |
| for w in lex["word_forms"]: |
| self._a_words.add_word(w, w) |
| self._a_words.make_automaton() |
|
|
| |
| |
| |
| |
| self.cn_only: dict[int, list[bool]] = {} |
| for gid, g in enumerate(self.groups): |
| mem = [self.strings[i] for i in g["m"]] |
| top = max(self.freq.get(m, 0) for m in mem) or 1 |
| self.cn_only[gid] = [ |
| bool(fo) and self.freq.get(m, 0) / top < CN_ONLY_RATIO |
| for m, fo in zip(mem, g.get("fo", [False] * len(mem)))] |
|
|
| |
|
|
| def build(self, text: str) -> Lattice: |
| lat = Lattice(text=text) |
|
|
| |
| exc_spans: list[tuple[int, int, list[int]]] = [] |
| for end, s in self._a_exc.iter(text): |
| exc_spans.append((end - len(s) + 1, end + 1, self.exceptions[s])) |
|
|
| |
| word_spans = self._longest_nonoverlap(self._a_words.iter(text)) |
|
|
| for end, form in self._a_sites.iter(text): |
| start = end - len(form) + 1 |
| end = end + 1 |
| gid = self.form2group[form] |
| g = self.groups[gid] |
| members = [self.strings[i] for i in g["m"]] |
| obs_ix = members.index(form) |
|
|
| crossing, contained = self._word_relation(start, end, word_spans) |
| g_type = self.rules[g["r"][obs_ix]]["t"] |
| if crossing and g_type == "variant_char": |
| continue |
|
|
| kill = [False] * len(members) |
| for ci, cand in enumerate(members): |
| if ci == obs_ix: |
| continue |
| rid = self.pairs.get((form, cand), g["r"][ci]) |
| rule = self.rules[rid] |
| if self._exception_hit(start, end, rid, exc_spans): |
| kill[ci] = True |
| elif not self._positional_ok(text, start, end, rule): |
| kill[ci] = True |
| lat.edges.append(Edge(start, end, gid, obs_ix, kill, |
| crossing, contained)) |
| lat.edges.sort(key=lambda e: (e.start, -(e.end - e.start), e.gid)) |
| return lat |
|
|
| @staticmethod |
| def _longest_nonoverlap(hits) -> list[tuple[int, int]]: |
| spans = sorted(((end - len(w) + 1, end + 1) for end, w in hits), |
| key=lambda s: (s[0], -(s[1] - s[0]))) |
| out: list[tuple[int, int]] = [] |
| last = -1 |
| for s, e in spans: |
| if s >= last: |
| out.append((s, e)) |
| last = e |
| return out |
|
|
| @staticmethod |
| def _word_relation(start: int, end: int, |
| word_spans: list[tuple[int, int]]) -> tuple[bool, bool]: |
| crossing = contained = False |
| for ws, we in word_spans: |
| if we <= start: |
| continue |
| if ws >= end: |
| break |
| if (ws < start < we < end) or (start < ws < end < we): |
| crossing = True |
| if ws <= start and end <= we and (ws, we) != (start, end): |
| contained = True |
| return crossing, contained |
|
|
| @staticmethod |
| def _exception_hit(start: int, end: int, rid: int, |
| exc_spans: list[tuple[int, int, list[int]]]) -> bool: |
| for xs, xe, rids in exc_spans: |
| if xs <= start and end <= xe and (xs, xe) != (start, end) \ |
| and rid in rids: |
| return True |
| return False |
|
|
| @staticmethod |
| def _positional_ok(text: str, start: int, end: int, rule: dict) -> bool: |
| """負向 positional 是否決;正向(before/after)存在時須至少滿足一個。""" |
| pos_req, pos_ok = False, False |
| for kind, arg in rule["po"]: |
| if kind == "not_after" and text[max(0, start - len(arg)):start] == arg: |
| return False |
| if kind == "not_before" and text[end:end + len(arg)] == arg: |
| return False |
| if kind in ("before", "after"): |
| pos_req = True |
| if kind == "before" and text[end:end + len(arg)] == arg: |
| pos_ok = True |
| if kind == "after" and text[max(0, start - len(arg)):start] == arg: |
| pos_ok = True |
| return pos_ok if pos_req else True |
|
|
| |
|
|
| def edge_features(self, lat: Lattice, edge: Edge, |
| reveal_observed: bool) -> np.ndarray: |
| """[C, FEAT_DIM]。reveal_observed=False 用於 cloze 預訓練: |
| observed 相依的維度(is_keep、長度差)歸零,避免標籤洩漏。""" |
| g = self.groups[edge.gid] |
| members = [self.strings[i] for i in g["m"]] |
| obs = members[edge.obs_ix] |
| lo = max(0, edge.start - CLUE_WINDOW) |
| ctx = lat.text[lo:edge.end + CLUE_WINDOW] |
| top_freq = max(self.freq.get(m, 0) for m in members) |
|
|
| out = np.zeros((len(members), FEAT_DIM), dtype=np.float32) |
| for ci, cand in enumerate(members): |
| rid = self.pairs.get((obs, cand), g["r"][ci]) |
| rule = self.rules[rid] |
| f = out[ci] |
| if reveal_observed: |
| f[0] = float(ci == edge.obs_ix) |
| f[55] = (len(cand) - len(obs)) / 6.0 |
| t_ix = RULE_TYPES.index(rule["t"]) if rule["t"] in RULE_TYPES \ |
| else RULE_TYPES.index("other") |
| f[1 + t_ix] = 1.0 |
| for d in rule["d"]: |
| if d < N_DOMAINS - 3: |
| f[11 + d] = 1.0 |
| if not rule["d"]: |
| f[11 + N_DOMAINS - 2] = 1.0 |
| f[47] = min(sum(1 for c in rule["pc"] if c in ctx), 5) / 5.0 |
| f[48] = min(sum(1 for c in rule["nc"] if c in ctx), 5) / 5.0 |
| f[49] = float(bool(rule["en"]) and rule["en"].lower() |
| in lat.text.lower()) |
| fq = self.freq.get(cand, 0) |
| f[50] = math.log10(fq + 1) / 7.0 |
| f[51] = {None: 0.5, "low": 0.0, "high": 1.0}.get(rule["cf"], 0.5) |
| f[52] = float(fq == top_freq) |
| f[53] = len(cand) / 6.0 |
| f[54] = len(members) / 8.0 |
| f[56] = float(edge.word_contained) |
| f[57] = float(edge.word_crossing) |
| f[58] = float(g["io"][ci]) |
| f[59] = float(edge.cand_kill[ci]) |
| return out |
|
|
| |
|
|
| def to_arrays(self, lat: Lattice, s_max: int, c_max: int |
| ) -> dict[str, np.ndarray] | None: |
| """定長陣列。site_gold = obs_ix(真實語料上 observed 即正解)。 |
| 溢出時依優先序裁邊:lexical 規則邊 > 可遮罩 variant > 不可遮罩 variant。""" |
| edges = lat.edges |
| if len(edges) > s_max: |
| def prio(e: Edge): |
| g = self.groups[e.gid] |
| t = self.rules[g["r"][e.obs_ix]]["t"] |
| return (0 if TAU_GROUP.get(t) != "variant" else |
| 1 if g["mk"][e.obs_ix] else 2) |
| edges = sorted(edges, key=lambda e: (prio(e), e.start))[:s_max] |
| edges.sort(key=lambda e: (e.start, -(e.end - e.start), e.gid)) |
|
|
| n = len(edges) |
| if n == 0: |
| return None |
| arr = { |
| "site_span": np.zeros((s_max, 2), dtype=np.int16), |
| "site_gid": np.full(s_max, -1, dtype=np.int32), |
| "site_gold": np.zeros(s_max, dtype=np.int8), |
| "site_ncand": np.zeros(s_max, dtype=np.int8), |
| "site_kill": np.zeros((s_max, c_max), dtype=bool), |
| "site_maskable": np.zeros(s_max, dtype=bool), |
| "n_sites": np.int16(n), |
| } |
| for i, e in enumerate(edges): |
| g = self.groups[e.gid] |
| arr["site_span"][i] = (e.start, e.end) |
| arr["site_gid"][i] = e.gid |
| arr["site_gold"][i] = e.obs_ix |
| arr["site_ncand"][i] = min(len(g["m"]), c_max) |
| arr["site_kill"][i, :len(e.cand_kill)] = e.cand_kill[:c_max] |
| arr["site_maskable"][i] = g["mk"][e.obs_ix] |
| return arr |
|
|
|
|
| MASK_ID = 2 |
|
|
|
|
| def build_masked_view(ids: np.ndarray, spans: np.ndarray, maskable: np.ndarray, |
| mask_id: int = MASK_ID |
| ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: |
| """把可遮罩站點收合成單一 MASK token 的序列視圖。 |
| |
| 訓練 collate 與 runtime **必須共用此函式**——遮罩政策只依 observed 形式 |
| (maskable 是 form 的函數),兩邊分佈才一致。 |
| |
| 重疊站點共用 MASK:以 (start, -len) 貪婪選出不重疊的遮罩單元, |
| 其餘站點的 m_span 透過索引投影落在覆蓋它的 MASK 位置(可含殘餘可見字元)。 |
| |
| 回傳 (masked_ids, m_spans[n,2], old2new[len+1])。 |
| """ |
| n = len(ids) |
| order = sorted(range(len(spans)), |
| key=lambda i: (int(spans[i][0]), -(int(spans[i][1]) - int(spans[i][0])))) |
| units: list[tuple[int, int]] = [] |
| last = -1 |
| for i in order: |
| if not maskable[i]: |
| continue |
| s, e = int(spans[i][0]), int(spans[i][1]) |
| if s >= last: |
| units.append((s, e)) |
| last = e |
|
|
| old2new = np.zeros(n + 1, np.int32) |
| segs: list[np.ndarray] = [] |
| prev = pos = 0 |
| mask_tok = np.array([mask_id], dtype=ids.dtype) |
| for s, e in units: |
| for k in range(prev, s): |
| old2new[k] = pos + (k - prev) |
| pos += s - prev |
| segs.append(ids[prev:s]) |
| segs.append(mask_tok) |
| for k in range(s, e): |
| old2new[k] = pos |
| pos += 1 |
| prev = e |
| for k in range(prev, n): |
| old2new[k] = pos + (k - prev) |
| pos += n - prev |
| segs.append(ids[prev:n]) |
| old2new[n] = pos |
| masked = np.concatenate(segs) if segs else ids[:0] |
|
|
| m_spans = np.zeros((len(spans), 2), np.int32) |
| for i, (s, e) in enumerate(spans): |
| a = int(old2new[int(s)]) |
| b = max(int(old2new[int(e)]), a + 1) |
| m_spans[i] = (a, b) |
| return masked, m_spans, old2new |
|
|
|
|
| def density_report(builder: LatticeBuilder, texts: list[str]) -> dict: |
| """邊密度統計,決定 S_MAX。""" |
| import collections |
| ns, per_type = [], collections.Counter() |
| for t in texts: |
| lat = builder.build(t) |
| ns.append(len(lat.edges)) |
| for e in lat.edges: |
| g = builder.groups[e.gid] |
| per_type[builder.rules[g["r"][e.obs_ix]]["t"]] += 1 |
| ns_arr = np.array(ns) |
| return {"n_texts": len(texts), |
| "sites_mean": round(float(ns_arr.mean()), 2), |
| "sites_p50": int(np.percentile(ns_arr, 50)), |
| "sites_p95": int(np.percentile(ns_arr, 95)), |
| "sites_p99": int(np.percentile(ns_arr, 99)), |
| "sites_max": int(ns_arr.max()), |
| "per_type": dict(per_type.most_common())} |
|
|