| """TWLAT V3 推論管線。 |
| |
| 輸入 → protect(PUA 遮罩保護段)→ safe_normalize(twv) |
| → lattice(字典硬過濾在此發生)→ 無邊 → fast path |
| → clean/masked 雙 pass forward → Viterbi + τ → 最小 splice |
| → restore → 輸出 |
| |
| 超長輸入:滑動視窗(stride 384),每條邊由「它最居中」的視窗評分—— |
| 不再有 V2 的 INPUT_TOO_LONG 降級路徑。 |
| """ |
| from __future__ import annotations |
|
|
| import pathlib |
| from dataclasses import dataclass, field |
|
|
| import numpy as np |
| import torch |
|
|
|
|
| from twlat import decoder |
| from twlat.features import (FEAT_DIM, L_MAX, LexTables, |
| assemble_cands, make_feat, site_arrays, |
| text_arrays) |
| from twlat.lattice import LatticeBuilder, build_masked_view |
| from twlat.model_v3 import TWLATV3, make_config |
| from twlat.normalize import safe_normalize |
| from twlat.paths import data_file |
| from twlat.protect import protect, restore |
|
|
| import json |
|
|
| WIN, STRIDE = 512, 384 |
|
|
|
|
| @dataclass |
| class ResultV3: |
| output: str |
| decisions: list = field(default_factory=list) |
| fast_path: bool = False |
| error: str | None = None |
|
|
|
|
| class TWLATV3Runtime: |
| def __init__(self, ckpt: str, device: str | None = None, |
| tau: dict[str, float] | None = None, |
| lexicon_path=None, fo_bonus: float = 0.0): |
| lex_p = lexicon_path or data_file("dict/lattice_lexicon.json") |
| self.lb = LatticeBuilder(lex_p) |
| self.lex = LexTables(lex_p) |
| self.vocab = json.loads( |
| data_file("dict/char_vocab_v3.json").read_text(encoding="utf-8")) |
| self.fo_bonus = fo_bonus |
| tp = data_file("model/tau.json") |
| self.tau = tau if tau is not None else ( |
| json.loads(tp.read_text()) if tp.exists() else decoder.DEFAULT_TAU) |
| self.device = torch.device( |
| device or ("cuda" if torch.cuda.is_available() |
| else "mps" if torch.backends.mps.is_available() else "cpu")) |
| ck = torch.load(ckpt, map_location="cpu", weights_only=False) |
| self.model = TWLATV3(make_config(**ck["mcfg"])) |
| self.model.load_state_dict(ck["model"]) |
| self.model.eval().to(self.device) |
| |
| |
| self.reveal = ck.get("phase") == "finetune" |
| ck_lex = ck.get("lexicon_version") |
| if ck_lex and ck_lex != self.lb.version: |
| |
| self.lexicon_mismatch = (ck_lex, self.lb.version) |
| else: |
| self.lexicon_mismatch = None |
|
|
| |
|
|
| @torch.no_grad() |
| def score_batch(self, texts: list[str], batch_size: int = 32): |
| """評分階段:→ list[(base, sv, lat, logits[n_edges,C] | None)]。 |
| τ 校準重用此結果做多組解碼,不必重跑模型。""" |
| stages = [] |
| jobs = [] |
| for ti, t in enumerate(texts): |
| q, sv = protect(t) |
| base = safe_normalize(q, do_twv=True) |
| lat = self.lb.build(base) |
| stages.append([base, sv, lat, None]) |
| if not lat.edges: |
| continue |
| wins = self._windows(len(base)) |
| assign = self._assign(lat, wins) |
| for wi, (ws, we) in enumerate(wins): |
| eixs = assign[wi] |
| if not eixs: |
| continue |
| jobs.append(self._make_job(ti, base[ws:we], ws, |
| [lat.edges[i] for i in eixs], eixs)) |
|
|
| logit_map: dict[int, dict[int, np.ndarray]] = {} |
| jobs.sort(key=lambda j: len(j["text"])) |
| for s in range(0, len(jobs), batch_size): |
| chunk = jobs[s:s + batch_size] |
| out = self.model(self._stack(chunk)) |
| lg = out["cand_logits"].float().cpu().numpy() |
| for bi, j in enumerate(chunk): |
| m = logit_map.setdefault(j["ti"], {}) |
| for k, eix in enumerate(j["eixs"]): |
| m[eix] = lg[bi, k] |
|
|
| for ti, st in enumerate(stages): |
| lat = st[2] |
| if not lat.edges: |
| continue |
| lm = logit_map.get(ti, {}) |
| cmax = max((v.shape[0] for v in lm.values()), default=1) |
| logits = np.full((len(lat.edges), cmax), -np.inf, np.float32) |
| for eix, row in lm.items(): |
| logits[eix, :len(row)] = row |
| for i, e in enumerate(lat.edges): |
| if i not in lm: |
| logits[i, e.obs_ix] = 0.0 |
| st[3] = logits |
| return stages |
|
|
| def decode_stage(self, stage, tau=None) -> ResultV3: |
| base, sv, lat, logits = stage |
| if logits is None: |
| return ResultV3(output=restore(base, sv), fast_path=True) |
| edits = decoder.decode(self.lb, lat, logits, |
| self.tau if tau is None else tau, |
| fo_bonus=self.fo_bonus) |
| return ResultV3( |
| output=restore(decoder.splice(base, edits), sv), |
| decisions=[{"span": [e.start, e.end], "from": e.observed, |
| "to": e.replacement, "utility": round(e.utility, 4), |
| "rule_type": e.rule_type} for e in edits]) |
|
|
| @torch.no_grad() |
| def convert_batch(self, texts: list[str], batch_size: int = 32 |
| ) -> list[ResultV3]: |
| return [self.decode_stage(s) |
| for s in self.score_batch(texts, batch_size)] |
|
|
| |
|
|
| @staticmethod |
| def _windows(n: int) -> list[tuple[int, int]]: |
| if n <= WIN: |
| return [(0, n)] |
| wins, s = [], 0 |
| while True: |
| wins.append((s, min(s + WIN, n))) |
| if s + WIN >= n: |
| return wins |
| s += STRIDE |
|
|
| @staticmethod |
| def _assign(lat, wins) -> dict[int, list[int]]: |
| """每條邊 → 它最居中的視窗。""" |
| out: dict[int, list[int]] = {wi: [] for wi in range(len(wins))} |
| for i, e in enumerate(lat.edges): |
| best_wi, best_d = None, None |
| for wi, (ws, we) in enumerate(wins): |
| if e.start >= ws and e.end <= we: |
| c = (ws + we) / 2 |
| d = abs((e.start + e.end) / 2 - c) |
| if best_d is None or d < best_d: |
| best_wi, best_d = wi, d |
| if best_wi is not None: |
| out[best_wi].append(i) |
| return out |
|
|
| def _make_job(self, ti, text, offset, edges, eixs): |
| ids, script, prot = text_arrays(text, self.vocab) |
| |
| shifted = [] |
| for e in edges: |
| se = type(e)(e.start - offset, e.end - offset, e.gid, e.obs_ix, |
| e.cand_kill, e.word_crossing, e.word_contained) |
| shifted.append(se) |
| sa = site_arrays(self.lb, shifted, text) |
| mids, m_span, _ = build_masked_view(ids, sa["span"], sa["maskable"]) |
| mscript, _, _ = build_masked_view(script, sa["span"], sa["maskable"], |
| mask_id=4) |
| mprot, _, _ = build_masked_view(prot.astype(np.uint8), sa["span"], |
| sa["maskable"], mask_id=0) |
| cand_tok, cand_mask, cand_kill, cand_feat = assemble_cands( |
| self.lex, sa["gid"], sa["obs"], sa["clue"], sa["eng"], |
| sa["flags"], sa["kill"], reveal_observed=self.reveal) |
| return {"ti": ti, "eixs": eixs, "text": text, |
| "ids": ids, "script": script, "prot": prot, |
| "feat": make_feat(script, prot, sa["span"], len(text)), |
| "mids": mids, "m_span": m_span.astype(np.int64), |
| "mfeat": make_feat(mscript, mprot, m_span, len(mids)), |
| "c_span": sa["span"], "obs": sa["obs"], |
| "cand_tok": cand_tok, "cand_mask": cand_mask, |
| "cand_kill": cand_kill, "cand_feat": cand_feat} |
|
|
| def _stack(self, jobs): |
| B = len(jobs) |
| T = max(len(j["ids"]) for j in jobs) |
| Tm = max(len(j["mids"]) for j in jobs) |
| S = max(len(j["c_span"]) for j in jobs) |
| C = max(j["cand_tok"].shape[1] for j in jobs) |
| out = { |
| "ids": np.zeros((B, T), np.int64), |
| "feat": np.zeros((B, T, 4), np.int64), |
| "pad": np.zeros((B, T), bool), |
| "mids": np.zeros((B, Tm), np.int64), |
| "mfeat": np.zeros((B, Tm, 4), np.int64), |
| "mpad": np.zeros((B, Tm), bool), |
| "c_span": np.zeros((B, S, 2), np.int64), |
| "m_span": np.zeros((B, S, 2), np.int64), |
| "site_mask": np.zeros((B, S), bool), |
| "cand_tok": np.zeros((B, S, C, L_MAX), np.int64), |
| "cand_mask": np.zeros((B, S, C), bool), |
| "cand_kill": np.zeros((B, S, C), bool), |
| "cand_feat": np.zeros((B, S, C, FEAT_DIM), np.float32), |
| } |
| for b, j in enumerate(jobs): |
| n, tm = len(j["ids"]), len(j["mids"]) |
| ns, c = len(j["c_span"]), j["cand_tok"].shape[1] |
| out["ids"][b, :n] = j["ids"] |
| out["feat"][b, :n] = j["feat"] |
| out["pad"][b, :n] = True |
| out["mids"][b, :tm] = j["mids"] |
| out["mfeat"][b, :tm] = j["mfeat"] |
| out["mpad"][b, :tm] = True |
| out["c_span"][b, :ns] = j["c_span"] |
| out["m_span"][b, :ns] = j["m_span"] |
| out["site_mask"][b, :ns] = True |
| out["cand_tok"][b, :ns, :c] = j["cand_tok"] |
| out["cand_mask"][b, :ns, :c] = j["cand_mask"] |
| out["cand_kill"][b, :ns, :c] = j["cand_kill"] |
| out["cand_feat"][b, :ns, :c] = j["cand_feat"] |
| return {k: torch.from_numpy(v).to(self.device) for k, v in out.items()} |
|
|