| """V3 特徵組裝:pretrain_data(離線)、train_v3(collate)、runtime_v3(線上) |
| 三方共用的唯一實作——訓練與推論的特徵分佈必須 bit-consistent。 |
| |
| 分工備忘: |
| - 靜態特徵(rule type/domain/freq/conf…)以**成員歸屬規則**(group["r"][ci]) |
| 編碼進 LexTables.static; |
| - 語境相依特徵(clue 命中/english anchor)以 **(observed, cand) pair 規則** |
| 在文本上計算(site_arrays)。 |
| 兩者的規則來源不同是刻意的:pair 規則才知道「這個轉換方向」的語意條件。 |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import math |
| import pathlib |
|
|
| import numpy as np |
| import regex |
|
|
| from twlat.paths import data_file |
|
|
| SEQ, S_MAX, C_MAX, L_MAX = 512, 128, 8, 8 |
| HAN_VOCAB = 4096 |
| HASH_SPACE = 59000 |
| FEAT_DIM = 64 |
| CLUE_WINDOW = 40 |
| MASK_ID = 2 |
|
|
| HAN = regex.compile(r"\p{Han}") |
| LATIN = regex.compile(r"[A-Za-z]") |
| DIGIT = regex.compile(r"\p{Nd}") |
| PROTECT = regex.compile(r"https?://\S+|[\w.+-]+@[\w-]+\.[\w.]+|`[^`]+`" |
| r"|[A-Za-z][A-Za-z0-9_.+-]{2,}") |
|
|
| RULE_TYPES = ["cross_strait", "variant_char", "tw_phrase", "confusable", |
| "ai_filler", "translationese", "variant", "political_coloring", |
| "typo", "other"] |
| RT_IX = {t: i for i, t in enumerate(RULE_TYPES)} |
|
|
|
|
| def enc_char(ch: str, vocab: dict) -> int: |
| i = vocab.get(ch) |
| return i if i is not None else HAN_VOCAB + (ord(ch) % HASH_SPACE) |
|
|
|
|
| def text_arrays(text: str, vocab: dict) -> tuple[np.ndarray, np.ndarray, np.ndarray]: |
| """→ (ids int64[n], script uint8[n], prot bool[n])""" |
| n = len(text) |
| ids = np.zeros(n, np.int64) |
| script = np.zeros(n, np.uint8) |
| prot = np.zeros(n, bool) |
| for i, ch in enumerate(text): |
| ids[i] = enc_char(ch, vocab) |
| script[i] = 1 if HAN.match(ch) else 2 if LATIN.match(ch) else \ |
| 3 if DIGIT.match(ch) else 0 |
| for m in PROTECT.finditer(text): |
| prot[m.start():m.end()] = True |
| return ids, script, prot |
|
|
|
|
| def site_arrays(lb, edges, text: str) -> dict[str, np.ndarray]: |
| """lattice edges → 站點中繼陣列(無 gold;gold 由呼叫端投影)。""" |
| ns = len(edges) |
| lowered = text.lower() |
| a = {"span": np.zeros((ns, 2), np.int64), |
| "gid": np.zeros(ns, np.int32), |
| "obs": np.zeros(ns, np.int64), |
| "maskable": np.zeros(ns, bool), |
| "kill": np.zeros((ns, C_MAX), bool), |
| "clue": np.zeros((ns, C_MAX, 2), np.uint8), |
| "eng": np.zeros((ns, C_MAX), bool), |
| "flags": np.zeros(ns, np.uint8)} |
| for k, e in enumerate(edges): |
| g = lb.groups[e.gid] |
| members = [lb.strings[i] for i in g["m"]] |
| obs = members[e.obs_ix] |
| a["span"][k] = (e.start, e.end) |
| a["gid"][k] = e.gid |
| a["obs"][k] = e.obs_ix |
| a["maskable"][k] = g["mk"][e.obs_ix] |
| a["kill"][k, :len(e.cand_kill)] = e.cand_kill[:C_MAX] |
| a["flags"][k] = int(e.word_contained) | (int(e.word_crossing) << 1) |
| ctx = text[max(0, e.start - CLUE_WINDOW):e.end + CLUE_WINDOW] |
| for ci, cand in enumerate(members[:C_MAX]): |
| rid = lb.pairs.get((obs, cand), g["r"][ci]) |
| rule = lb.rules[rid] |
| if rule["pc"]: |
| a["clue"][k, ci, 0] = min(sum(1 for c in rule["pc"] if c in ctx), 5) |
| if rule["nc"]: |
| a["clue"][k, ci, 1] = min(sum(1 for c in rule["nc"] if c in ctx), 5) |
| if rule["en"]: |
| a["eng"][k, ci] = rule["en"].lower() in lowered |
| return a |
|
|
|
|
| class LexTables: |
| """gid → 候選 token / 靜態特徵 展開表(collate 與 runtime 共用)。""" |
|
|
| def __init__(self, lexicon_path=None, vocab_path=None): |
| lexicon_path = lexicon_path or data_file("dict/lattice_lexicon.json") |
| vocab_path = vocab_path or data_file("dict/char_vocab_v3.json") |
| lex = json.loads(pathlib.Path(lexicon_path).read_text(encoding="utf-8")) |
| vocab = json.loads(pathlib.Path(vocab_path).read_text(encoding="utf-8")) |
| self.version = lex["version"] |
| strings, rules, freq = lex["strings"], lex["rules"], lex["freq"] |
| G = len(lex["groups"]) |
| self.tok = np.zeros((G, C_MAX, L_MAX), np.int64) |
| self.ncand = np.zeros(G, np.int8) |
| self.length = np.zeros((G, C_MAX), np.float32) |
| self.static = np.zeros((G, C_MAX, FEAT_DIM), np.float32) |
| self.fo = np.zeros((G, C_MAX), bool) |
| for gid, g in enumerate(lex["groups"]): |
| mem = [strings[i] for i in g["m"]][:C_MAX] |
| for ci, flag in enumerate(g.get("fo", [])[:C_MAX]): |
| self.fo[gid, ci] = flag |
| self.ncand[gid] = len(mem) |
| top = max(freq.get(m, 0) for m in mem) |
| for ci, m in enumerate(mem): |
| for k, ch in enumerate(m[:L_MAX]): |
| self.tok[gid, ci, k] = enc_char(ch, vocab) |
| self.length[gid, ci] = len(m) |
| r = rules[g["r"][ci]] |
| f = self.static[gid, ci] |
| f[1 + RT_IX.get(r["t"], RT_IX["other"])] = 1.0 |
| for d in r["d"]: |
| if d < 33: |
| f[11 + d] = 1.0 |
| if not r["d"]: |
| f[11 + 34] = 1.0 |
| fq = freq.get(m, 0) |
| f[50] = math.log10(fq + 1) / 7.0 |
| f[51] = {None: 0.5, "low": 0.0, "high": 1.0}.get(r["cf"], 0.5) |
| f[52] = float(fq == top) |
| f[53] = len(m) / 6.0 |
| f[54] = len(mem) / 8.0 |
| f[58] = float(g["io"][ci]) |
|
|
|
|
| def assemble_cands(lex: LexTables, gid, obs, clue, eng, flags, kill, |
| reveal_observed: bool): |
| """→ (cand_tok, cand_mask, cand_kill, cand_feat),C 裁到本組最大候選數。""" |
| C = int(lex.ncand[gid].max()) if len(gid) else 1 |
| cand_tok = lex.tok[gid][:, :C] |
| cand_feat = lex.static[gid][:, :C].copy() |
| cand_mask = np.arange(C)[None, :] < lex.ncand[gid][:, None] |
| cand_kill = kill[:, :C].copy() |
| cand_kill[~cand_mask] = False |
| cand_feat[:, :, 47] = clue[:, :C, 0] / 5.0 |
| cand_feat[:, :, 48] = clue[:, :C, 1] / 5.0 |
| cand_feat[:, :, 49] = eng[:, :C] |
| cand_feat[:, :, 56] = (flags & 1)[:, None] |
| cand_feat[:, :, 57] = ((flags >> 1) & 1)[:, None] |
| cand_feat[:, :, 59] = cand_kill |
| cand_feat[:, :, 60] = lex.fo[gid][:, :C] |
| if reveal_observed: |
| ar = np.arange(C)[None, :] |
| cand_feat[:, :, 0] = (ar == obs[:, None]).astype(np.float32) |
| obs_len = lex.length[gid, obs] |
| cand_feat[:, :, 55] = (lex.length[gid][:, :C] - obs_len[:, None]) / 6.0 |
| return cand_tok, cand_mask, cand_kill, cand_feat |
|
|
|
|
| def make_feat(script: np.ndarray, prot: np.ndarray, spans, t: int) -> np.ndarray: |
| """4 通道 token 特徵:script / 在站點 span 內 / 保護段 / 詞界。""" |
| f = np.zeros((t, 4), np.int64) |
| f[:, 0] = script |
| for s, e in spans: |
| f[min(int(s), t):min(int(e), t), 1] = 1 |
| f[:, 2] = prot |
| f[1:, 3] = (script[1:] != script[:-1]).astype(np.int64) |
| return f |
|
|