唯音輸入法(vChewing)三元圖架構白皮書 (2026-11-11)
| 項目 | 內容 |
|---|---|
| 版本範圍 | v4.1.3(2025-11-30)~ v5.0.1(2026-11-11,GM Build 5010) |
| 開發階段 | 2026-10-23 起約三週(語料側 n-gram 主線);前置為 2026 年 8–9 月之 POM 供給 |
| 文件日期 | 2026-11-12 |
| 基準 | vChewing-macOS 5.0.1/vChewing-LibVanguard 8923d9a/vChewing-VanguardLexicon a0a1b209 |
| 性質 | 架構說明(architecture note),非提案;所記之決策均已落地,或已明確標為未啟用/界線 |
背景
兩款產品,一個容易混淆的名字
唯音(vChewing)是 macOS 上的注音/拼音輸入法;酷音(Chewing,即新酷音)是另一款更具歷史悠久之產品,以 C 語言起家、歷經三代架構、目前已用 Rust 徹底重寫。這兩個產品在程式碼上沒有上下游關係:唯音不連結 libchewing、不以酷音的引擎組句,酷音也不消費唯音的程式碼;兩者各自有自己的引擎與詞庫管線。
唯一一次歷史交集在詞庫建置這一層:2022 年底唯音的前身(威注音)曾把自己定位為「給酷音引擎等幾款其他的 FOSS 中文輸入法另立一套原廠 lexicon 建置套件」——說的是供一份詞庫建置管線,建置原廠詞庫給某些 FOSS 輸入法使用(包括新酷音輸入法),而不是唯音的程式碼依附於酷音。英文名遂取作 vChewing,在 Chewing 之前冠一個前綴。這個名字此後沒有再改,但那個定位早已不成立:唯音現在有自己的引擎與詞庫編譯器;vChewing-VanguardLexicon 仍保留 chewingCBased* 與 chewingRust* 生成器,能產出酷音可讀的辭典檔,但那是詞庫建置管線的輸出,不是程式碼的傳承,也不是「唯音是酷音的詞庫」。名字留了下來,成為每一份對外說明都必須先解釋一次的東西。
本文所述的三元圖 (trigram) 架構屬唯音。凡提及酷音之處皆為對照,並會明寫「酷音」;凡引用酷音社群之文件(例如〈新酷音輸入法架構更新提案〉,2026-11-12 版)亦同。
在需要使用純中文的表述環境下,敝專案以
三元圖代稱trigram、以複元圖代稱ngram。
詞頻是策展參數,不是樣本頻次
唯音的詞頻不是任何單一語料的樣本數。2022 年底的做法是把 NAER 的報刊、對話、國語教材等多個分類疊加,再整體施以對數曲線;此後數年,詞頻是逐詞、逐讀音依打字體驗手調的。因此:
phrases-*.txt、char-kanji-core.txt與 n-gram 表的數字是策展權重(curation knobs),不是出現次數。- 為解決特定問題而手調 n-gram/unigram 的次數,是正當手段,不須語料背書。
- 仍須守兩條:同一欄位語意一致(第 2 欄恆為「該前文下該詞之權重」、第 5 欄恆為「該前文本身之權重」);可追溯(手調列須在檔內註明其為手調與緣由)。
這件事決定了整個架構的取向。既然靜態分數不是正規化機率,就不能假設它「已經等於 P(w)」,也難以套用需要正規化假設的平滑法(Kneser-Ney、線性插值)——那些方法的前提在此不成立。唯音因此採用「加性對數 + 相對增益 + 閘門」的模型,而不是統計派的標準做法。這個取捨的好處與代價,見〈解碼器評分公式〉與〈已知界線〉。
一句話的管線
按鍵經 Tekkon 組成注音音節,LexiconAssembly 把原廠詞庫、使用者資料與 POM 合成候選元圖,Homa 在 DAG 上求最佳路徑,選字窗與組字區消費結果。本文談的是其中「分數從哪裡來」的那一段。
歷史沿革:從 unigram 到 sparse trigram
2002 年起的工業通用三層架構,與唯音的參照
2002 年酷音技術報告把系統分成使用者介面、斷詞、字詞庫三層,斷詞的本體是「在詞圖上找累計頻率最高的路徑」。這個三分並非酷音獨有;此後二十餘年,中文輸入法的骨架沒有變過:注音(或拼音、倉頡)→ 音節 → 詞圖 → 最優路徑。唯音的骨架在概念上與此相同(音節 → 詞圖 → 最優路徑),其早期的組句引擎 Megrez 亦循此形;但程式碼是獨立的:它不從酷音的分支長出來,也不共用其引擎;差別在每一層的邊界畫在哪裡、以及最優路徑的「分數」從哪裡來。
唯音的起點是 2022 年的威注音;當時的自我定位是「給酷音引擎等幾款其他的 FOSS 中文輸入法另立一套原廠 lexicon 建置套件」——說的是詞庫建置管線與其產出的原廠詞庫,不是程式碼的上下游。此後專案長出自己的組句引擎(Megrez)與自己的詞庫編譯器(VanguardLexicon);2025 年 11 月 30 日的 v4.1.3 是「唯音時代」的起點——此後開發以 LLM 輔助為主。其間 Homa 於 2025 年在 OS 中立的 LibVanguard 問世,2026 年 4 月接手 macOS 側的組句。到 v5.0.1(2026-11-11),這條路走完了一輪:詞庫、引擎、資料格式、驗證工具全部自有。
2025:Homa 與 bigram 的空位
vChewing-LibVanguard 現存歷史上最早的一筆 Homa 提交在 2025-02-15,訊息是 "Initial implementation with limited Bigram support"。也就是說,bigram 從一開始就在資料模型裡(Gram.previous),但沒有資料:詞庫只出 unigram,bigram 只能由使用者的選字記憶(POM)臨時供給。
2025-11-30 的 v4.1.3 是這個狀態的定點:辭典側純 unigram、POM 以覆寫分數介入、TextMap 是 v1.1,型別 C 的第 4 欄只承載一個前詞。這一欄在格式上就宣告了上限:能表達 bigram,不能表達 trigram。
2026 年 8 月:把 POM 餵成 n-gram
2026-08-29 的一批提交是第一次認真的嘗試:Homa 的 Gram 增加 anterior、getScore 加入 trigram 計分;TrieKit 的 Entry 增加第 5 欄 anterior;POM 開始把記憶當 bigram 與 trigram 餵進引擎。這批改動隨 v4.7.0(2026-08-30)出貨;v4.7.3(2026-09-05)再把「POM 作為預設 n-gram 來源」打開。
這一輪的成果與限制都很清楚:引擎有了 trigram 的形狀,但語料側仍是空的。而第 5 欄是「多加一欄」,不是「一條鏈」——鏈長固定為二、同詞多前文時整列重複、也無法表達「前前詞是 x、前詞不限」這種稀疏情形(缺口列要到 2026 年 11 月才有)。真正的前驗詞鏈要等下一次改格式。
2026 年 10–11 月:語料側的 trigram
主線集中在三週內完成。先立驗證路徑(Yukuaki 套件與 VanguardLexiconDebugger CLI),因為「資料不對時,得先能撇開審字因素、直接問該補什麼」;接著改格式、改解碼器;此後每一輪都有一條可量測的行為界線被推開:
| 日期 | 產出 |
|---|---|
| 10-23 | Yukuaki(輸入法無關的組句套件)+ VanguardLexiconDebugger CLI;TextMap 2.0(前驗詞鏈、條件分數) |
| 10-24 | 正向文脈 DP;鏈長計次 b + (k−1)(p−b);詞類模板 |
| 10-24 | POM 分數改為隨時間單調下探;語料挖掘器;節點保險單元圖 |
| 10-24/25 | TextMap 收束:型別 D 分組、嚴格載入、trigram 上限 |
| 10-25 | trigram 分母改為完整前文 count(x, y);挖掘器之詞長預設與讀音誠實化 |
| 11-08 | 缺口列([x, *]);查詢契約(簡拼過濾、確定序);使用者辭典之 #NGRAM: 列 |
| 11-08 | 匯出腳本分流;make sort 只重排資料列;逆推器列替代方案;資料檔改空白分欄 |
| 11-09 | TextMap 載入加速(bundled 檔略過逐行驗證);術語定名:Front Context/正向文脈、Rear Context/負向文脈 |
其結果隨 v5.0.0(2026-11-09)出貨,同版另含 TextMap 載入加速與使用者自訂候選序(後者非 n-gram 主線,同期)。v5.0.1(2026-11-11)再收 2026-11-10 之手調 19 列與使用者資料寫入巷。
同期另有閂滯態之 Shift+/ 寬度與選字窗格寬之調整;兩者與本文主題無直接關係。
版本對照
| 版本 | 日期 | 語言模型狀態 |
|---|---|---|
| v4.1.3 | 2025-11-30 | 辭典側 unigram;Megrez 組句;POM 覆寫;TextMap v1.1;型別 C 只有單一 previous |
| v4.7.0 | 2026-08-30 | POM 供 bigram/trigram;Gram.anterior;TextMap 第 5 欄 anterior |
| v4.7.3 | 2026-09-05 | POM 預設參與組句 |
| v4.9.2 | 2026-10-21 | 語料側 trigram 動工前夕 |
| v5.0.0 | 2026-11-09 | TextMap 2.0、正向文脈 DP、鏈長計次、缺口列、#NGRAM、完整前文分母、術語、載入加速、候選序 |
| v5.0.1 | 2026-11-11 | 手調 19 列、使用者資料寫入巷、使用者片語增量收列 |
現有問題
以下以 v4.9.2(2026-10-21,主線動工前)之狀態為準。
辭典側只有 unigram。 原廠詞庫的每一條詞都只有一個無條件分數;同音詞的取捨全靠詞頻與長度先驗。POM 能補一點語境,但那是「這個使用者最近選過什麼」,不是「這個詞在語言裡常跟誰一起出現」。
格式只承載得了一個前詞。 TextMap v1.1 的型別 C 第四欄是 previous;v4.7.0 追加第五欄 anterior 之後,trigram 勉強有地方放,但鏈長固定、同詞多前文時整列重複,且無法表達缺口。
組句 DP 的狀態只有位置。 f(i) = max_j (f(j) + cost(j, i)) 只在「未來與過去條件獨立」時精確。n-gram 讓它不獨立:第 3 鍵的「在/再」取決於後面接的詞(「這樣」)——而那時它還沒被讀到。實測「你不要再這樣」:舊引擎給「在」——不是因為「再」的分數低,而是「再」那條路在第 3 鍵就被砍了;後面的「這樣」根本沒有機會替它辯護。
節點在內部先定案。 就算把前文塞進狀態,Node.evaluate(previous:anterior:) 仍只回傳唯一一條選中的元圖;「在」與「再」共用同一個 ㄗㄞˋ 節點,勝負在節點內部就分完了,DP 只拿到一個數字。
trigram 的分母錯了。 TextMap 2.0 初期,trigram 的條件分數用「緊鄰前一個詞」的詞頻作分母(count(y)),正解是完整前文(count(x, y))。差額 −log10 q_c(x|y) 對同一前文是常數,故同一前文下的排序不變;但它把 trigram 的絕對分數整體壓低——量測 68,233 個 trigram 型別,中位數壓低 37 倍、p90 壓低 390 倍——使幾乎所有 trigram 恆為懲罰。早期量測所稱「n-gram 貢獻恆 ≤ 0」正是這個錯誤分母撐出來的結構性上限。
節點若沒有單元圖,基線就沒有定義。 override 評估與鏈長計分都以「同節點之單元圖」為錨;節點只帶有前文的元圖時,Homa 退 grams[0]、Yukuaki 退 0,兩引擎對同一份資料給出不同分數,組句結果不可比。
POM 的分數方向是錯的。 舊制 score = −freqFactor × (1 − age/wT)² × 0.114514:年齡核隨時間遞減,分數反而趨近 0(變強),越老的記憶越像語料統計;且硬截止處由 −0.0001 直落 −13.001。它與語料側的 log10 條件分數也不同源。
無語境的查詢吃得到條件分數。 狂打模式之簡拼整詞查詢(以首字母縮寫一次查整詞)無前文可比較,卻自 TextMap 2.0 起吃得到帶前驗詞鏈的條目;且其原廠資料源以 Array(Dictionary.values) 收尾,迭代序跨行程不保證——同一詞在簡拼清單裡的分數逐次啟動可能不同,α 自動套用之「明勝」判定遂不可重現。
使用者資料無法表達語境。 userdata-*.txt 只有 值 讀音 [分數] [#標記];而匯出腳本只認前三格、一律當單元圖輸出——條件分數會被當無條件次數投餵進原廠詞庫。
資料檔與工具都在沉默。 phrases-ngram-*.txt 原以 TAB 分欄,TAB 被編輯器換成空格後整列只剩一欄,舊制 guard cells.count >= 4 else { continue } 靜默丟棄:不報錯、不記日誌,該列形同不存在。挖掘器的詞長預設值則因頂層 let 的賦值時序而恆為 1,測試全綠——因為測試皆顯式給定詞長。
目標
新的架構必須解決前述問題,並滿足以下設計目標。
前文可變長,但止於兩層。 trigram 是上限(事主〔專案所有者〕裁定「ngram 最長定為 trigram」)。更長的鏈只存在於資料層與顧問工具,引擎不消費。
解碼要精確,不要 beam。 狀態數靠「相關值正規化」壓回 1——無 n-gram 資料時與舊 1D DP 完全等價;有資料時才分裂成多條假設。不引入近似搜尋。
n-gram 只做相對增益。 分數是加性對數;n-gram 不得覆寫單元圖、不得冒充無條件分數,候選須先過 p > baseline 的閘門。
兩種契約分明。 原廠詞庫嚴格(錯一列即拒,附行號);使用者資料寬容(壞列只留診斷,絕不整體失敗)。這兩種契約刻意相反,見〈檔案格式與配置〉。
可回答「為什麼」與「該補什麼」。 給定讀音鏈能看組句結果;結果不對時,能在撇開審字因素的前提下反推「該補哪些 n-gram、補到多高的次數才會翻盤」。
可重現。 同一輸入 → 同一輸出;排序不得依賴 Dictionary 迭代序;詞庫重建須可逐位元組比對。
詞頻是策展參數。 不假裝是樣本頻次;手調列須可追溯、同一欄位語意一致。這條目標與上一條並不完全相容——策展參數改動即改變輸出——故「可重現」的範圍限定在「同一份資料與同一份程式」。
架構綜覽
模組
| 段 | 模組 | 職責 |
|---|---|---|
| 注拼 | Tekkon | 按鍵 → 注音音節;並擊、自動切音節、拼音與雙拼之前置轉換 |
| 辭典 | VanguardTrie/TrieKit | 讀音鍵 → 詞條;出貨格式為 VanguardTextMap(.txtMap),另有惰性 TextMapTrie |
| 辭典聚合 | LexiconAssembly | 原廠詞庫、使用者資料、POM 之合流;LXFacade/LXQuerier/LXPerceptor |
| 組句 | Homa | 依序插入讀音、建節點與幅節(segment:一次查詢所涵蓋的讀音段)、DAG 動態規劃、候選輪替與覆寫 |
| 宿主 | vChewing-macOS | IMK 回呼、組字區、選字窗、偏好;LibVanguard 以子套件鏡像 |
一句話:Tekkon 決定「有哪些音」,TrieKit 決定「每個音有哪些字」,LexiconAssembly 決定「這些字從哪裡來、彼此怎麼排」,Homa 決定「整句選哪一條路」。
離線與線上
離線側是詞庫編譯器:純文字詞表與 n-gram 表經 Collector 折算成權重,由 DataBuilder_VanguardTextMap 產出 .txtMap。線上側只有讀取:.txtMap 進 Trie,Trie 的詞條經 LXFacade 轉成 Homa.Gram,POM 的記憶在解碼期以同一種元圖追加。這條分界是「可重現」的基礎——靜態分數永遠與使用者狀態脫鉤。
兩個引擎
組句引擎有兩份實作,邏輯必須一致:
Homa(護摩)——出貨引擎,住vChewing-LibVanguard,隨輸入法執行。名字取自《原神》胡桃的「護摩之杖」。Yukuaki(行秋)——輸入法無關的離線子集,住vChewing-VanguardLexicon,只保留「讀音 → 組句」所需的部分(Assembler、PathFinder、Node、Gram、Segment、PossibleKey、Config、GramQuerier),剝掉候選輪替、組字區、游標與 POM。名字取自《原神》行秋。
Yukuaki 不是「另一個引擎」,而是 Homa 的可驗證鏡像:兩者以同一套算則與同一批權重撰寫,並各有平行測試釘住逐位元相同的輸出。之所以另立而不連結 Homa,理由有二——授權(Homa 為 LGPL-3.0-or-later,Yukuaki 走 MulanPSL-2.0)與職責(Homa 的整組控制 API 對「離線驗證詞庫」毫無用處)。
同代架構的對照
同期有三個可比的設計,各自的取捨不同:
- 小麥注音的 gramambular2:語言模型是純 unigram 介面,給定讀音回傳一組
(value, score),模型本身不含使用者狀態;解碼是 DAG 上的 Viterbi(DAG-DP;此形式由 Chiahong Hong 於 2026 年引入,見〈與其他架構的對照〉),使用者選字以解碼期覆寫(kOverridingScore = 42)表達,不寫回機率表。這是「引擎極薄、其餘留給外圍」的路線;代價見後文。 - 酷音的 v4 提案(〈新酷音輸入法架構更新提案〉,2026-11-12 版):unigram 加選配 bigram,bigram 作為邊上的轉移成本,以 Stupid Backoff 回退;解碼沿用 m-A* 求 N-best;使用者偏好改為衰減計數表加
user_freq.csv雙向微調。這是「把統計派的標準做法補齊」的路線。 - Mozc/libpinyin 一類:bigram(或詞類 bigram)加 Viterbi,節點成本+連接成本;詞類 bigram 把轉移矩陣收斂到
|詞類|²。
唯音走的是第四條:稀疏 trigram + 策展權重 + 精確 DP。它不追求 dense 的轉移矩陣,而是「只補會翻盤的那幾列」;代價是沒有 dense bigram 的 backoff 支撐,於是需要缺口列與詞類模板來補稀疏。這個取捨的細節貫穿下文。
變更項目與理由
以下十一項依施工次序排列;每項記「舊系統/新系統/理由」,並在必要處補記界線。
1. TextMap 2.0:前驗詞鏈取代單一前詞
舊系統:型別 C 為 value\tprobability\ttypeID[\tprevious],第 4 欄只能放一個前詞;v4.7.0 追加的第 5 欄 anterior 是補丁,鏈長固定為二。
新系統:型別 C 的第 4 欄升為前驗詞鏈——以 escaped-pipe 編碼、由舊至新、最多兩詞;第 5 欄廢除。另立型別 D 承載「同詞值、同 typeID、多筆前文」的分組:
#PRAGMA:VANGUARD_HOMA_LEXICON_VALUES
館 -0.362 6 圖|書
!馆 5 -2.534 案 -0.29 档|案
(第二列為簡體軌之實例,故字形為簡體。)
理由:改動範圍最小——只動一欄的語義與版號,其餘結構、跳脫層、KEY_LINE_MAP 不動。兩條解析路徑(全物化 VanguardTrieIO 與惰性 TextMapTrie)同拒非 2.0 之檔;產製端、Debugger、測試樣本三處同步。
型別 D 是為「同鍵多前文」消去重複欄,不是為了當下的行數:2026-10 量測時,語料只有 4 筆帶前文條目,D 只省下 2 行、10 bytes(451,003 → 451,001 行)。把它寫進格式的理由是將來語料變密時的形狀,不是眼前的收益。
2. 條件分數與完整前文分母
舊系統:TextMap 2.0 初期 p₃ = log₁₀(L(w) × count(x, y, w) / count(y));分母是「緊鄰前一個詞」。
新系統:p(w|c) = log₁₀(L(w) × q(w|c)),其中 q(w|c) = count(c, w) / count(c) 是條件次數比、c 為完整前文、L(w) = e^{|w|/3−1} 為長度先驗。p 是未正規化的對數分數,不是機率:L(w) 使它不再歸一。trigram 列必須寫第 5 欄 count(x, y),缺欄即報錯、不回退;bigram 列可沿用 count(y)(legacy),來源以 NgramDenominatorSource(fullContext/legacyUnigramCount/normalizedFallback)記錄,供測試與除錯分辨。
理由:分母錯了,q 就不再是該前文的條件次數比,p 的絕對尺度被整體壓低一個 δ = log₁₀(count(y)/count(x,y)),連帶影響 p > b 閘門與跨路徑總分。量測(zh.wikipedia 隨機條目,cht 82 篇/98,753 詞次;單位依比例推定,原始記錄作「82 句」,待核 stats.json):
| 量測 | 結果 |
|---|---|
δ = log₁₀(count(y)/count(x, y)),68,233 個 trigram 型別 | p10 0.46/中位數 1.57/p90 2.59/最大 3.91 |
Σ_w q_c(w|x, y)(量測語料的條件次數比,不含長度先驗) | 定義上恆為 1.0000(40,717 個前文);改採 count(y) 時中位數 0.0179 |
上表量的是量測語料的條件次數比 q_c(w|c) = count(c,w)/count(c);固定前文下 Σ_w q_c(w|c) = 1 是分母定義的直接結果,而 p 加上長度先驗後、Σ_w 10^{p(w|c)} 一般不等於 1。表中 68,233 型是語料中觀測到的 trigram 型別,與挖掘器 min-count 2 的 5,696 列 backlog 不是同一集合(該次量測另以 min-count 2 --min-context-count 2 跑,與發布預設 min-count 5 不同)。同一個詞的邊際比 q_c(w) = count(w)/N_corpus(N_corpus 取 cht 軌的 98,753 詞次,單軌)與條件比 q_c(w|c) 尺度相差 1–4 個數量級,兩者相比幾乎必然成立,不能當作分母修正的證據。
解碼器閘門用的是詞庫側的分數。p 與基線 b 都由 weighten 折算(n-gram 端是 Collector_Ngram 的 weighten(norm: count(前文)),單元圖端是 Collector_Core 的 weighten(norm:));在同字數(|w| = |w₀|;|w| 是字數、不是音節數)時兩側的 L 相同,p > b 等價於 10^{p}/L > 10^{b}/L——這是閘門的定義(把兩側換成同一個長度先驗下的比值;對分數而言是重言式),不是統計驗證。第 2 節排除的是拿量測語料的 q_c(w|c) > q_c(w) 當分母修正的證據;兩者的差別是「定義」與「證據」,不可混用。此後未標下標的 q 皆指詞庫側的條件次數比;手調時仍須守同一欄位語意一致與可追溯。
分母修正的證據是 δ 分佈(中位數 1.57,即 37 倍)與 Σ_w q_c = 1 的定義性;出貨詞庫上 p > b 的實際通過率與翻盤率尚未量測。
因 δ 對同一前文是常數,同一前文下之候選排序不變;變的是三處:① p > baseline 這道「能否進 DP」的閘門(同一節點內與單元圖基線比較);② 跨路徑總分之比較;③ 鏈長計次把位移放大成兩倍。這也是為什麼「只修分母、不動權重模型」是一個可獨立驗收的改動。
3. 正向文脈 DP:狀態升維與候選推進
正向文脈是尚未讀入、卻能令先前同音詞翻盤的一側;已讀入、供 n-gram 查表的前驗詞鏈則是負向文脈。
舊系統:狀態只有位置,f(i) = max_j (f(j) + cost(j, i));節點以 evaluate(previous:anterior:) 回傳唯一一條選中的元圖。
新系統:狀態為 (位置, 前驗詞, 前前驗詞);節點不再自行定案,改由 candidates(of:previous:anterior:relevantValues:) 列出候選,DP 逐條假設推進。
理由:只把前文塞進狀態還不夠。第一版升維後案例 2 仍失敗(差 0.709),根因在節點粒度——「在」與「再」共用同一個 ㄗㄞˋ 節點,勝負在節點內部就分完。改成「節點列候選、DP 帶假設」之後才成立。此處的紀律有四條:記「這條路徑」實際選中的候選元圖(估值之後的那條,不是估值前殘留的 currentGram);段字典迭代 keys.sorted();只在勝出路徑上 adopt(gram:);弱釘選只壓分數、不動選取。
效能之縫:先掃全圖收集 relevantValues(所有 gram 的 previous ∪ anterior),不在集合內的詞值一律正規化為 nil(此處的「正規化」是把不在集合內的值映射為 nil,與機率正規化無關)。這讓「沒有 n-gram 資料」的普通句子永遠只有一個狀態,與舊 1D DP 全等;有資料時才分裂。這是精確優化,不是近似。
4. 按前文鏈長計次
舊系統:bigram 與 trigram 的增益等權,都只加一次 p。
新系統:
public var chainLength: Int { 1 + (previous == nil || hasWildcardPrevious ? 0 : 1) + (anterior == nil ? 0 : 1) }
public func weightedScore(against baseline: Double) -> Double {
baseline + Double(chainLength - 1) * (probability - baseline)
}
理由:分母修正(第 2 項)讓 trigram 的絕對分數回到應有的位置;鏈長計次處理的是另一個問題——即使分數正確,若每個 n-gram 的貢獻都是定額,兩條假設的勝負就只取決於各自增益之比,與鏈長無關,長鏈與短鏈證據等權,正向文脈即使成立也扳不動負向文脈。按鏈長計次,長鏈證據才更有力。
| 鏈長 | 公式 | 與舊制之別 |
|---|---|---|
| 1(unigram) | baseline | 不變 |
| 2(bigram) | probability | 逐位元相同 |
| 3(trigram) | 2 × probability − baseline | 增益算兩次 |
兩條性質必須說清楚。其一,單調:∂W/∂p = k − 1 > 0;候選又須先過 p > b,故 W > b,不會讓低於基線的候選入選。其二,W 不是機率,且可以為正——W = b + (k−1)(p−b);當基線很低而條件分數相對高時會落在 0 以上。p 沒有恆負的保證(長度先驗在 |w| > 3 時大於 1),故 W 也沒有 −b 上界;案例 3 之 +0.5 即是。這正是保險單元圖(−114.514)會被抬得極高的原因;保險只保證「基線有定義」,不保證語義正確。
5. 缺口列
舊系統:前文鏈只能表達「連續的前 1~2 個詞」。「距離 2 是 x、距離 1 是誰都行」無從表達,而這正是 trigram 缺資料時唯一的退路。
新系統:*(U+002A)佔 previous 格,代表該格不設限、其貢獻已聚合進分母。三條硬規則:
*只准居兩格前文鏈之末;[*]與[*, y]一律報錯(後者退化為 bigram)。- 鏈長只數受約束之格:
[x, *]為 2,不是 3;否則鏈長計次會把單一前文證據算成 trigram 而把增益算兩次。 - 互斥:同一
(x, y, w)不得同時有缺口列與 trigram 列。缺口列涵蓋所有y,故只要該(x, w)已有任一 trigram 列,缺口列即不得存在。強制點在產製端parseNgramLines收尾——那裡同時看得見兩類列。
理由:對 y 聚合後的 count₂(x, ·) 支撐比 count(x, y, w) 厚一個量級。案例 3 的正式解法就是缺口列,而非 trigram(見〈案例:三個句子〉)。
縫:PathFinder.candidates 原以 guard let previous, !previous.isEmpty 擋掉所有非單元圖列;而缺口列最常在「previous 不在 relevantValues 而被正規化成 nil」的稀疏狀態下才該生效。此處不動,規則會寫進去卻永不觸發,且無任何錯誤訊息。* 必須存成哨兵字串、不得存 nil——存 nil 會讓鏈長掉回 1、weightedScore 退化成 baseline。
6. 詞類模板
舊系統:逐詞列舉共現。
新系統:詞值欄與前驗詞鏈欄都可以寫 <TOKEN>,由 phrases-ngram-classes-*.txt 展開成逐條成員;兩處同時引用時取笛卡兒積;詞值為類名時讀音欄寫 -,改以成員在 unigram 表中的讀音入庫,查無讀音者一律略過、不臆造。
理由:再/在 的取捨由後接的詞類決定,這是語法事實,不是逐詞共現統計。事主原文:「不 要 再 接下来必接动词。不 要 在 接下来必接地点名词。」類名一律英文 token、繁簡共用(<PLACE_NOUN>/<OBJECTIVE_VERB>/<SELF_VERB>),僅成員隨語系而異——邏輯不必為繁簡分支。
現狀:模板列仍以 # 註解。理由有二——模板給的是「同一類別共用的統一增益」,不是逐詞統計;且成員能否成為候選仍卡在 p > baseline(實測 在 之 count 6528 配 count = 5 時 p ≈ −3.261,而「這裡」之單元圖為 −2.881,模板扳不動;要扳動得 count ≥ 12)。機制先通、權重待語料或手調。
7. 使用者辭典的 #NGRAM 列
舊系統:userdata-{cht,chs}.txt 只能承載單元圖(值 讀音 [分數] [#標記])。
新系統:值 讀音 [分數] #NGRAM:前文鏈。閘門與前文同格——宣告與內容不可分離,故「寫了前文卻忘了宣告」不可能發生。NGRAM 不分大小寫,其後須為 :;裸 #NGRAM 或鏈不合格者整列不生效(不得落回單元圖,否則「宣告了卻寫壞」會靜默地以條件分數充當無條件分數);無閘門者一律按單元圖讀。前文鏈以 | 分隔、由舊至新;* 只准居兩詞鏈之末。診斷只進 vCLMLog、只跑一次。
理由:使用者資料之解析一律「絕不整體失敗」——與原廠 TextMap 之嚴格載入契約刻意相反。八條診斷(漏寫閘門、分數寫在標籤後、NGRAM 未知變體等)只記日誌,不阻擋載入。
消費端三處守衛:單音節加冕只及於單元圖(否則單音節 n-gram 列會被重建為單元圖並改寫分數);isFrequencyControlledPair 限 isUnigram(純 n-gram 列係語境偏好、非頻率釘);簡拼查詢只取單元圖。不開放:無 GUI 入口、無 i18n、不改範本、不進手冊(事主裁定:現階段僅供進階使用者測試)。
8. POM 的分數語義
舊系統:score = −freqFactor × (1 − age/wT)² × 0.114514,分數隨年齡趨近 0(變強);硬截止處由 −0.0001 直落 −13.001。
新系統:分數自 −kWeightMultiplier(≈ −0.1145)單調下探至 threshold + 0.002(≈ −12.998);t = (age/wT)^convexity、convexity = 0.5 + 0.5 × freqFactor(用得越多、下探越慢);達 wT 以 threshold − 0.001 硬截止。LXFacade 直接餵絕對分數、不經 p(w|c) 折算——兩側同為 log10 語義,Node 與 DP 直接比;但帶前文的 POM 元圖仍照 weightedScore 走鏈長計次(見〈已知界線〉)。
理由:事主裁定「剛生成的 POM 永遠壓過語料,但隨著時間推移則不一定」。舊制的方向恰好相反。此後 POM 與語料共用一條數線,Node 不必知道某個分數來自記憶還是詞庫。
界線:頻率只調曲線形狀、不動端點——同一時刻新生的記憶,無論用過幾次,起點都是 −0.1145;用得多次者只是「活得較強」。中段(例如五日齡 ≈ −8.8)是否壓過語料取決於對手,這正是「不一定」。
9. 查詢契約:無語境者不得吃條件分數
舊系統:狂打之簡拼整詞查詢吃得到帶前驗詞鏈之條目;factoryChoppedUnigramsFor 以 Array(Dictionary.values) 收尾,迭代序跨行程不保證。
新系統:查詢尾端(去重與排序之前)加 guard gram.isUnigram;factoryChoppedUnigramsFor 改確定序(機率降冪、同分按 (詞值, previous, anterior) 升冪)。
理由:簡拼無前文可比較,p(詞|前文) 在此無從求值;放行則既抬高名次,也可能令 α 自動套用之明勝門檻(≥ 3.0)失去語境依據。排序則關乎可重現:同一詞的單元圖與 n-gram 兩條俱存,Dictionary 序一旦不確定,消費端「先按詞值去重、先到先得」就吃到哪條不確定。修在源頭而非消費端,因為該未爆彈對所有未來呼叫端皆成立。
10. 節點的保險單元圖
舊系統:節點若連一條單元圖都沒有,基線只能退化成 0 或借用其他元圖之分數——同一份資料在兩引擎上得出不同基線。
新系統:節點之元圖陣列若非空卻連一條單元圖都沒有,就在最前面補一條無語境元圖:值與讀音取陣列第一條、分數 −114.514。
理由:override 評估與鏈長計分都以「同節點之單元圖」為錨;錨不存在,分數就不可比。此值遠低於任何真實單元圖,故無語境者不可能在組句中勝出;它只保證「基線有定義」。
11. 資料檔可編輯性與工具鏈
資料檔:phrases-ngram-*.txt 改為「空白分欄 + -/| 子分隔」——欄界是一段空白,欄內不得再有空白;讀音以 - 連接、前文鏈以 | 連接。故 TAB 與空格互換不改變語意。make sort 改為「以 # 起首即註解、空行亦非資料列,只排序連續資料列區段」,並加「輸出須為原檔單純重排」護欄。
工具鏈:VanguardLexiconCorpusMiner(fetch/segment/mine)產候選列;VanguardLexiconDebugger(-k/-e,離開碼 0/1/2)逐條驗;Yukuaki.Advisor 逆推所需次數。
理由:TAB 會被部分編輯器換成空格,舊制解析器靜默丟棄整列——這種沉默比報錯更貴。工具鏈的目的則是讓「該補什麼」有算術答案,而不是靠人眼比對。
前文的表示與索引
唯音沒有全域 word_id。詞條以讀音鍵索引(- 連接的音節鏈),一個讀音鍵下可掛多筆詞條;每筆詞條是 (value, typeID, probability, context[])。前文以詞值表示,不帶讀音——讀音級的共現統計在現有語料上湊不出樣本。
| 概念 | 表示 | 備註 |
|---|---|---|
| 詞條 | (讀音鍵, value, typeID, probability, context[]) | typeID 5/6 為繁/簡軌,其餘為標點、CNS、假名等 |
| 前文 | context: [String],由舊至新 | 空陣列=unigram |
| 前詞/前前詞 | context.last/context[count-2] | 計算屬性,既有呼叫點不動 |
| 萬用符 | "*"(哨兵字串) | 不得存 nil;只准居兩格鏈之末 |
| 鏈長 | 1 + (previous≠nil) + (anterior≠nil),* 不計 | 缺口列為 2 |
| 去重身分 | (value, context) | TextMap 2.0 起含前文;否則同詞的 bigram/trigram 會在進引擎前被合併 |
| 語系軌 | typeID 5/6 | 帶前文之條目只取本軌;單元圖跨軌並列 |
「去重身分含前文」是一處易漏的斷點:makeFactoryUnigrams 原本只取 value/probability,context 在此被靜默丟棄——修好解析器也救不回來。同族的 Array<Homa.Gram>.consolidate(filter:) 與 factoryChoppedUnigramsFor 的去重身分也必須含前驗詞鏈。
檔案格式與配置
| 檔案 | 角色 | 格式 | 誰可編輯 |
|---|---|---|---|
VanguardFactoryDict4Typing.txtMap | 出貨詞庫(含 n-gram) | 三段式 PRAGMA、TAB 分欄 | 否(由編譯器產出) |
phrases-ngram-{cht,chs}.txt | n-gram 來源表 | 純文字、4–6 欄、空白分欄 | 是(維護者) |
phrases-ngram-classes-{cht,chs}.txt | 詞類清單 | 純文字、空白分欄 | 是 |
userdata-{cht,chs}.txt | 使用者詞語 | 純文字、#NGRAM: 列 | 是(進階使用者) |
char-kanji-core.txt/phrases-*.txt | unigram 來源 | 純文字 | 是(維護者) |
| POM 之 WAL 與快照 | 使用者選字記憶 | 二進位 | 否 |
本文在資料格式名稱中沿用的「機率」(DEFAULT_PROB、型別 B 的 @ 前綴分數等)皆指策展對數分數,不是正規化機率。
TextMap v2.0
三段式:HEADER/VALUES/KEY_LINE_MAP。HEADER 首行必須是 VERSION\t2.0;其餘為 READING_SEPARATOR、ENTRY_COUNT、KEY_COUNT、各 typeID 的 DEFAULT_PROB_<id>。
VALUES 有四種列形(A/B/C/D),另有一條舊四欄格式的相容分支(僅 TYPING)。以下每列之後的 # … 為說明,非檔案內容:
#PRAGMA:VANGUARD_HOMA_LEXICON_VALUES
>6 這裡 那裡 哪裡 # 型別 A:合併行(示意;無前文、機率為 DEFAULT_PROB)
@-2.715 我们 我們 # 型別 B:繁簡機率分組(示意;僅 TYPING)
館 -0.362 6 圖|書 # 型別 C:個體行,第 4 欄為前驗詞鏈
!馆 5 -2.534 案 -0.29 档|案 # 型別 D:同詞值多前文,2–16 筆
- 前驗詞鏈以 escaped-pipe 編碼、由舊至新,最多兩詞;跳脫字元為
\|、\s(空白)、\a(BEL)、\\。掃描一律逐 Unicode scalar——空白若與組合符相連,以Character迭代會把兩者併成單一字群。 - 型別 D 只在同一讀音節點內、同
(value, typeID)至少兩筆時成行;每行 2–16 筆,17 拆 15+2、33 拆 16+15+2,避免末組落單。 DEFAULT_PROB只採無前文條目;帶前文者的權重是另一個統計條件下的數值。ENTRY_COUNT實為 VALUES 行數,不是條目數;型別 D 分組會使其下降。- 嚴格契約:只接受明確
VERSION\t2.0;全物化、惰性初始化與 Debugger 三路都驗證全部 VALUES(惰性只延後詞條物化)。欄數、D 筆數、空 token、懸空跳脫、未跳脫 ASCII 空白、結構控制字元、空詞值、非有限Double、第三層前文,一律報錯。ZWJ 等Cf不誤傷。 - v5.0.0 起,受 app 簽章保護的 bundled 檔可略過逐行 VALUES 驗證、key map 改 byte 解析:init 由 0.74 秒降至 0.19 秒;外部匯入檔仍走嚴格路徑。逐行驗證的實測成本約 23 µs/列(全物化 5.671 秒/244,478 列)。
phrases-ngram-*.txt
4–6 欄、以空白(空格或 TAB)分隔;# 起首與空行忽略。欄位:詞值、次數、讀音(- 連接)、前驗詞鏈(| 連接,由舊至新)、完整前文詞頻(trigram 必填)、# 起首之單一註解 token。
館 249 ㄍㄨㄢˇ 圖|書 294
碳 85 ㄊㄢˋ 氧|化 100
的 62 ㄉㄜ˙ 在|* 1000
這樣 2 ㄓㄜˋ-ㄧㄤˋ 再 1000
- 詞類模板與第 5 欄互斥:成員展開後無法回指單一前文。
- 缺口列
[x, *]與同(x, w)的 trigram 列互斥,由parseNgramLines收尾驗。 - 檔內註明數值之性質(策展參數)、各列來源(語料實測或手調)與手調緣由——這是「可追溯」的兌現方式。
userdata-*.txt 的 #NGRAM: 列
[!WARNING] 開發者現階段不建議使用者輕易使用該自訂特性,除非純粹是以偵錯為目的測試尋找合適的 ngram。使用者自行新增的內容可能會與隨後的唯音輸入法的原廠詞庫內的 ngram 產生相容性問題。
的話 ㄉㄜ˙-ㄏㄨㄚˋ -7.5205 #NGRAM:再|這樣
的 ㄉㄜ˙ -1.4970 #NGRAM:在|*
分數是 p(詞|前文) = log₁₀(L(w)·q(詞|前文)),與原廠 n-gram 同數線;唯其如此,p > baseline 的閘門與鏈長計次才成立。無分數者按預設權重;閘門不明或鏈不合格者整列不生效。
詞類清單
<PLACE_NOUN> 這裡 那裡 哪裡 家 學校 公司 店 房間 廁所 廚房 辦公室 車上 路上 樓上 樓下
<OBJECTIVE_VERB> 說 看 讀 寫 吃 喝 買 賣 問 找 用 學 做 見 聽 拿 給 教 開 關
<SELF_VERB> 來 去 走 睡 玩 等 想 試 哭 笑 坐 站 跑 飛 死 病 活
第一欄為類名(含角括號),其後為成員;繁簡兩檔共用同一套類名,僅成員不同。成員清單是「種子」,加詞即生效,不必改程式碼。
解碼器評分公式
節點分數
對一個帶前文 c、鏈長 k = |c| + 1 的候選詞 w:
其中 b(w) 是同名節點的單元圖基線:實作為「元圖陣列中排序後的第一條單元圖」,而陣列以幅節長度降冪、機率降冪整理,故通常即該節點最高分的單元圖。|w| 是詞值的字數(不是音節數),e^{|w|/3-1} 是長度先驗(三字詞為 1)。q 是詞庫側的條件次數比(未標下標時皆指詞庫側);p 是加上長度先驗的對數分數,不是機率(Σ_w 10^{p(w|c)} ≠ 1)。p 通常為負,但長度先驗在 |w| > 3 時大於 1,故沒有恆負的保證;score 則不一定。
候選閘門:非單元圖候選須 p > b。在同字數(|w| = |w₀|;|w| 是字數、不是音節數)的情形,兩側的長度先驗相消,p > b 等價於 10^{p}/L > 10^{b}/L(同字數時 L 相同;b 為基線那條單元圖的分數)——也就是「候選在此前文下的等效比值,須高於節點最強單元圖的等效比值」。這是節點內的相對門檻,不是機率檢定;兩側都是策展權重,故它保證排序上的一致性,不保證統計顯著性。低分的 junk n-gram 因此不可能反客為主;代價是「n-gram 只能往相對好的方向推」,不能懲罰。
POM 的元圖不經 p(w|c) 之折算:它的 probability 已是絕對 log10 分數。注入之後則與語料元圖一視同仁——帶前文者同樣以 weightedScore 參與節點內的比較。POM 自身在挑選記憶時仍以原始機率為準(此界線未動)。
轉移、終止與回溯
DP 以 layers[位置] 與 layerLookup[位置][(previous, anterior)] 索引狀態;每個狀態記 score/parentID/gram/isExplicit/segLength。轉移是:
for 起點 i(由 0 至 keyCount):
for 狀態 s ∈ layers[i]:
for 幅節長度 ℓ ∈ segments[i].keys.sorted():
for 候選 g ∈ candidates(node, s.previous, s.anterior):
key = (canon(g.current), canon(s.previous))
layers[i+ℓ][key] = max(layers[i+ℓ][key], s.score + g.score)
同鍵只留最高分(同分首見者勝);終點取 layers[keyCount] 之最高分;回溯沿 parentID,且只在勝出路徑上 adopt(gram:)——推導期間不動節點,否則同一節點會被「最後一次估值」污染。
狀態數最壞為 n × |V|²,但受相關值正規化壓制:沒有 n-gram 資料時每個位置恰好一個狀態。這是精確的,不是 beam——精確性依賴三項不變量:① 在單次組句期間,所有候選元圖的分數已固定;給定狀態 (位置, previous, anterior),candidates(node, s.previous, s.anterior) 的枚舉結果與各候選分數即已決定,與到達該狀態的路徑無關(語料元圖、POM 注入的元圖、缺口列與使用者 #NGRAM: 列皆然;POM 的時變性發生在注入前,DP 內不重算;weightedScore 依賴的 b 是節點常數,不隨路徑變化);② relevantValues 收自所有節點的所有元圖,故不在集合內的值不可能匹配任何轉移;③ 缺口列 [x, *] 的 * 佔 previous 格,故 previous 不參與匹配;匹配只靠 anterior 的 x(* 須存為哨兵字串、不得存 nil),而覆寫在候選枚舉時已決定、不依賴未來。三者成立時,合併狀態才不丟資訊。
平手規則的可重現性同樣依賴具體走訪序:段長度以 keys.sorted() 走訪、bestRelevantUnigrams 以詞值升冪輸出、狀態以插入序保存在陣列;layerLookup 只查不迭代,故不引入 Dictionary 的迭代序。
候選枚舉
candidates 的取捨規則皆是精確規則:
- 覆寫為
.withSpecified:只回該元圖與overridingScore。 - 覆寫為
.withTopGramScore(弱釘選):回當前元圖、以單元圖基線計分——只壓分數、不動選取。 - 其餘:對非單元圖,逐條檢查 previous(缺口列不檢查)、anterior 與
p > b,合格者以weightedScore計分;對單元圖,「會被之後 n-gram 引用到的詞值」各留一條(同值取機率最高),其餘只留機率最高的一條。 - 整個節點連一條單元圖都沒有:退回排序後第一條元圖(與
Node.evaluate的無前文分支一致)。
與 POM 的合流
POM 的記憶在解碼期以 Homa.Gram 的形態追加,LXFacade.pomContextualGrams 直接餵絕對分數。它只經 DP 生效:候選窗之 fetchPOMSuggestion 只置頂無前後文者,簡拼查詢過濾 isUnigram。這條界線讓「條件分數」不會出現在無語境的查詢裡。
參數初始值
| 參數 | 值 | 說明 |
|---|---|---|
| 前文鏈上限 | 2 詞(k ≤ 3) | 事主裁定「ngram 最長定為 trigram」 |
| 候選閘門 | p > b | 條件分數須高於同節點單元圖基線 |
| 鏈長權重 | k − 1 | bigram 1、trigram 2;weightedScore |
| 保險單元圖 | −114.514 | 節點無單元圖時補入 |
| POM 起點 | −0.114514 | 剛生成;kWeightMultiplier |
| POM 淘汰閾值 | −13.0 | 硬截止 −13.001 |
| POM 視窗 | 8 日(急速遺忘 0.5 日) | 單元圖 ×0.85、單讀音單漢字 ×0.8 |
| 名義分母(匯出) | 1000 | #NGRAM 列匯出原廠表時 |
| 顧問倍率上限 | 10× | 首選需跨數量級時改列 n-gram 替代方案 |
| 挖掘器預設 | min-count 5、min-context 5、margin 0.02 | Wilson 下界預設不設限 |
| TextMap 版號 | 2.0 | 只接受 VERSION\t2.0 |
建構流程
離線:詞庫編譯
Collector_Core讀 unigram 來源(char-*.txt/phrases-*.txt),weighten(norm:)折算成 log10 權重。Collector_Ngram讀 n-gram 表:展開詞類模板、驗缺口列互斥、解析分母、折算條件分數。DataBuilder_VanguardTextMap逐型別插入詞條:無前文且機率等於DEFAULT_PROB者併入型別 A;帶前文者依(value, typeID)分組,單筆走型別 C、多筆走型別 D。- 序列化:
VERSION\t2.0、KEY_LINE_MAP依讀音鍵排序、輸出穩定。 - 產物(2026-10 量測)244,478 條目行/206,512 讀音/13,896,947 bytes;
_NORM為 374,388,831.9686408。
語料挖掘
fetch 取維基條目(variant=zh-cn/zh-tw)、segment 以 txtMap 的單元圖做 Viterbi 分詞、mine 統計相鄰詞對與連續三詞。候選之條件分數以同一批語料的完整前文為分母;唯 p 嚴格大於同詞單元圖分數(加 margin)才入選,並反解 required = ceil(count(c) × 10^(p+margin) / e^(|w|/3−1))。輸出 backlog.tsv(附 lift/corpus/required/lb)、unknown-runs.tsv、ambiguous.tsv、stats.json。
兩條紀律寫在工具裡:分詞與候選同據一份 txtMap(循環論證,故「新詞發現」實際退化為「低頻共現的人眼檢視」);小語料的 lift 不可盡信(Wilson 95% 下界回答「至少這麼多」)。
逆推與驗證
VanguardLexiconDebugger -k <讀音鏈> -e <期望> 先組句,不符時由 Yukuaki.Advisor 逆推:
- 列舉期望結果的所有讀音分詞(含詞界),以「缺詞代價
(n+1)²、缺詞界 bigram 2、缺 trigram 1」挑落差最小者(先剔除需新造超過 4 音節長詞者)。 - 對每個缺口建
GramSpec(role:selecting/boosting/fabricating)。 - 「全體候選同步抬分」的邊距搜尋(≤ 64 次倍增),逐條剔除多餘提案,二分收緊至
1e-4。 - 以
Assembler重跑驗證;把分數反推成 count:count = denominator × 10^p / e^{|w|/3−1}。 - 首選需跨數量級(> 10×)時,續在完整候選集上測單條,結果入
Report.alternatives;同級取絕對頻次改動最小者。
離開碼 0=命中、1=參數或詞庫錯誤、2=組句不符或案例間提案衝突——可直接塞進 CI 作回歸鎖(不是準確率驗證)。這套逆推以原廠 TextMap 為輸入,不含 POM 與使用者 #NGRAM: 資料;真機上兩者都會改寫分數,故它回答的是「原廠詞庫還缺什麼」,不是「這台機器上為何選錯」。
手調與可追溯
不是所有列都來自語料。2026-11-10 的批次即為手調:19 列、次數一律為 1、第 5 欄是「為表達所需分數而選的名義分母」而非實際出現次數(p = log10(e^{|w|/3−1} × 1 / D),D 越大該列越弱)。各 D 取「令目標句恰好翻盤、且不再更強」之值,逐列以 Debugger 對繁簡兩軌試算。檔內逐列註明目標句與對手分數(例如「音量調小 → 音量條小(條 −5.117 對調 −5.191)」),並記明「未動任何 unigram:十九筆原皆可由抬高單元圖頻次解決,依『優先 n-gram、不足再動 unigram』之旨,一律改以條件分數列處理」。
案例:三個句子
三個案例共用前綴 ㄅㄨˋ-ㄧㄠˋ-ㄗㄞˋ,其後分別接 ㄓㄜˋ-ㄌㄧˇ(案例 1)、ㄓㄜˋ-ㄧㄤˋ(案例 2)、ㄓㄜˋ-ㄧㄤˋ-ㄉㄜ˙(案例 3)。它們分別對應三種機制。
以下以 | 分隔組句結果的詞界(與前文鏈的 | 有別)。算術用引擎測試用的 mock 辭典(兩引擎逐位元相同);真實詞庫之量測另列於後。
| 詞 | 讀音 | 單元圖 |
|---|---|---|
| 不 | ㄅㄨˋ | −4.5 |
| 要 | ㄧㄠˋ | −4.0 |
| 在 | ㄗㄞˋ | −5.009 |
| 再 | ㄗㄞˋ | −5.5 |
| 這樣 | ㄓㄜˋ-ㄧㄤˋ | −5.2 |
| 這裡 | ㄓㄜˋ-ㄌㄧˇ | −5.5 |
| 的 | ㄉㄜ˙ | −1.5 |
B01 = 這樣 在 再 之後:p(這樣|再) = −4.0(bigram,k=2,W = −4.0)。 T01 = 的 在 在 這樣 之後:p(的|在, 這樣) = −0.5(trigram,k=3,W = 2(−0.5) − (−1.5) = +0.5)。其列形為 的 ㄉㄜ˙ -0.5 #NGRAM:在|這樣——前文鏈由舊至新;早期測試之舊記寫成「這樣 在」(前詞在前),與現行順序相反,本文一律用由舊至新的 #NGRAM:在|這樣(anterior=在、previous=這樣)。
案例 1「不要在這裡」→ 在。 不需任何 n-gram 條目:不|要|在|這裡 = −4.5 −4.0 −5.009 −5.5 = −19.009。舊引擎靠 unigram 就答對;這一例是對照組。
案例 2「不要再這樣」→ 再。 兩條假設:
| 路徑 | 算術 | 總分 |
|---|---|---|
| 不|要|在|這樣 | −4.5 −4.0 −5.009 −5.2 | −18.709 |
| 不|要|再|這樣 | −4.5 −4.0 −5.5 −4.0(B01) | −18.0 |
B01 把「這樣」由 −5.2 抬到 −4.0(增益 1.2),足以蓋過「在」對「再」的 0.491 優勢。但在舊 DP 下這條路根本不存在:「在」在第 3 鍵以 −5.009 勝過「再」的 −5.5,節點內部定案之後,「再」那條路已被砍,「這樣」沒有機會替它辯護。第一版只把前文塞進狀態、仍讓節點自行定案,差距 0.709——正是這個缺口;改成「節點列候選」之後才翻盤。
案例 3「不要在這樣的」→ 在。 這是最難的一例。T01 的增益 W = +0.5,比「的」的單元圖 −1.5 高 2.0;「在」路徑因此由 −20.209 升至 −18.209,勝過「再」路徑的 −19.5。負控(拿掉 T01)退回「再」——變異測試的鑑別力全在負控,因為案例 3 的正斷言在舊實作下仍會通過(「在」在負向文脈下是局部最優,T01 只是搭便車)。
真實詞庫的量測。 正式詞庫沒有 T01。案例 3 的正式解法是缺口列:
的 62 ㄉㄜ˙ 在|* 1000
p = log10(0.5134 × 62/1000) = −1.497,鏈長 2(* 不計),故 W = −1.497;它把「的」由單元圖 −4.976 抬到 −1.497(+3.479),案例 3 總分 −12.8590。案例 2 的正式解法是 這樣(前文 再)之 bigram 列(2/1000 → −2.844),令總分由 −11.3620 變為 −11.0670——兩條路徑的總分只差 0.295。mock 與出貨詞庫的 unigram 相對關係相反:mock 的「在」勝「再」0.491,出貨詞庫的「在」輸「再」0.003;同一條 B01 在兩邊的翻盤方向因此不同。
這些案例證明機制可達,不是準確率或泛化性的驗證:19 列手調與三案例共用同一批材料,沒有 held-out 句集。
為什麼 trigram 不行、缺口列行。 trigram 的形式要求「的」在完整前文下的條件次數比高到不合理的程度:它必須獨力償還「在」與「再」的落差、B01 的增益,以及「的」本身的單元圖優勢;缺口列把 y 聚合掉之後,只需名義比值 62/1000(q = 0.062;D = 1000 是名義分母,不是語料計數)即可成立。這不是資料多寡的問題,而是「完整前文」與「聚合前文」兩種估計量在稀疏資料下的支撐差異。缺口列的語意因此必須寫清楚:它的分數是 log₁₀(L(w)·q(w|x)) 的聚合版,不是 log₁₀(L(w)·q(w|x, y));出貨那列的 q(w|x) 由手調名義分母代替,挖掘器尚未產出真正的 count₂(x, ·)。
已知界線
鏈長止於兩層。 引擎只吃得到 trigram;更長的鏈在消費端截斷,僅存在於資料層與顧問工具。四階以上的需求(例如「我在檢查/我再檢查」這類需要更長前文才分得清的句子)刻意留給未來的機制,不以硬補 bigram 解決。
加性模型有結構上限。 n-gram 只能相對提升、不能懲罰;W 不是機率、可以為正。這使得「靠 n-gram 獨力償還先前選擇的落差」在數學上需要不現實的條件分數——案例 3 的 trigram 形式即為此。缺口列與手調列是繞道,不是解方。
鏈長計次沒有概率推導。 k−1 是「長鏈證據更有力」的設計選擇,不是獨立證據的乘積;其效果是讓含 trigram 的路徑總分虛高,應以語料與 A/B 驗證。
POM 的帶前文元圖照走鏈長計次。 低基線節點上,W = b + (k−1)(p−b) 會把接近 0 的 POM 分數放大(例:b=−5、p=−0.1145、k=3 ⇒ W≈+4.77)。L316 的「不經 p(w|c) 折算」不是繞過 weightedScore;是否讓 POM 繞過,屬另立 Phase 的決策。
保險基線會與鏈長計次相乘。 b=−114.514、p=−1.497、k=3 ⇒ W≈+111.5;正常詞庫不觸發(節點連一條單元圖都沒有時才補),但這是刻意保留的邊界,不是防護。
手調列的窗口很窄。 出貨詞庫中的「在」只輸「再」0.003;B01 一旦與 unigram 的相對關係改變即失效。要拓寬得動 unigram,不是再調 n-gram。
挖掘器不產缺口列。 兩趟聚合(先掃 trigram 再出缺口列)未做;出貨詞庫中的缺口列是手寫的一列,用途是讓該路徑在真實詞庫裡有列可走。
閘門的通過率未量測。 p > b 在出貨詞庫上的實際通過率與翻盤率尚未量測;第 2 節的 δ 只說明分母修正的尺度,不說明閘門的實際效果。
語料仍是抽樣。 cht 82 篇/98,753 詞次、chs 50 篇/30,191 詞次(單位依比例推定,待核),只夠驗證管線;實用前須以大而均衡之語料重跑。分詞與候選同據一份 txtMap,循環論證未解;抓取時的條目清單與修訂號未隨版控保存,重跑不會得到同一批語料。
效能只有常態量測。 HomaTests_Performance 以樣本句集之每句平均 < 5 ms 為門檻(未含 n-gram 密集句);n × |V|² 的最壞狀態峰值、候選數與記憶體沒有專門量測;目前也沒有狀態數上界或逾時降級規則。故「精確、不用 beam」是演算法上的保證,不是對最壞輸入的延遲保證。
詞類模板未啟用。 模板列仍以 # 註解:統一增益與逐詞統計的落差未解,且成員卡在 p > baseline。在 配 count = 5 時 p ≈ −3.261,扳不動「這裡」的 −2.881;要扳動得 count ≥ 12。
使用者側不開放。 #NGRAM: 無 GUI、無 i18n、不進手冊;語法只出現在進階使用者自己的檔案裡。要開放,範本、手冊、四語系文案與 GUI 提示是另一輪工作。
make sort 只保證非資料列不動,不保證資料列次序合於語意;同值多列仍按整行比較。
兩份實作、兩份契約、三處守衛。 Homa 與 Yukuaki 必須逐位元相同——這是測試的責任,不是型別的責任(限純詞庫路徑;POM、使用者資料與 override 的注入不在 Yukuaki 的範圍);#NGRAM 的加冕、控頻、簡拼三處守衛亦然。任何一處漏了,症狀都是「某個模式下安靜地選錯字」。
與其他架構的對照
| 維度 | 小麥注音 gramambular2 | 酷音 v4 提案(2026-11-12 版) | 唯音 v5.0 |
|---|---|---|---|
| 語言模型 | 純 unigram;模型不含使用者狀態 | unigram + 選配 bigram(Stupid Backoff) | 稀疏 trigram(最多兩層)+缺口列+詞類模板 |
| 解碼 | DAG 上 Viterbi,節點分數 | m-A* 求 N-best,bigram 落在邊上 | DAG 上精確 DP,狀態=位置+前兩詞 |
| 使用者偏好 | 解碼期覆寫(kOverridingScore = 42) | 衰減計數表+user_freq.csv 雙向微調 | POM 絕對 log10 分數+userdata 之 #NGRAM: 列 |
| 可重現 | 靜態 LM 可離線重建 | static_words.txt+static_lm.bin 分離 | 格式嚴格、詞庫由純文字重建;詞頻本身是策展參數 |
| 資料來源 | 語料 | 語料(可重訓) | NAER 多分類疊加+對數壓縮+多年手調 |
對 gramambular2:唯音沒有採用「純 unigram + 解碼期覆寫」,因為它要解的問題不同——唯音的詞庫以多字詞為主、詞頻策展,單字回退處的同音消歧正是 unigram 最弱的地方;而「覆寫」在本專案已有 POM 這條更細的路徑(時間衰減、絕對分數)。
也不該把 gramambular2 的「簡潔」當成唯音沒有的優點。它的簡潔是分工的結果:ReadingGrid 的職責是讀音、節點、DAG 走訪、游標、candidatesAt 與 overrideCandidate;marking 狀態、候選輪替、節點固定、關聯詞與使用者片語,則留給 KeyHandler、InputState 與候選控制器。代價是多出兩輪換算:buildInputtingState 先把 grid 的 reading 游標換成 UTF-8 byte 位移(其註解即寫「compute the UTF-8 cursor index」),再以該位移前的子字串之 NSString 長度得出 state 的 cursorIndex(UTF-16);marker 的行進則走 previousUtf16PositionFor:/nextUtf16PositionFor:——state 這一側是 UTF-16,grid 那一側是 reading 索引。
唯音的 Megrez 一開始就是針對這些弱點,重新檢討「引擎自身該集成哪些功能」——其 README 把「在獲取候選字詞內容的時候,不會出現橫跨游標的詞」列為專案特色;Homa 的重寫再往這個方向深化,EVOLUTION_MEMO 記的「內建了對候選字輪替與上下文節點鞏固功能的支援」即其結果;Yukuaki 則是反方向的分叉,它只需要「讀音 → 組句」,故把候選輪替、組字區、游標與 POM 全數剝掉。至於唯音資料層的四種列形、兩種契約、八條診斷,那是另一種取捨:把詞庫與 n-gram 的語義寫進格式,讓錯誤在載入時就現形。
時間上的先後也值得記一筆。唯音的 Megrez 與早期 gramambular2 一開始用的都是 DAG-Vertex,不是 DAG-DP。Megrez 於 2025 年 1 月 26 日改用 Dijkstra,同年 9 月 26 日再改為 DAG-DP;Homa 的初版(2025-02-15)用的也是 Dijkstra,2025 年 9 月 26 日改為 DAG-DP——兩代引擎的切換同日。小麥注音的 gramambular2 則要到 2026 年初,才由 Chiahong Hong 把 DAG 最短路徑改寫為 Viterbi 動態規劃(PR #777;1 月底 commit、2 月初併入)——此後才是今日的形狀。故以 DAG-DP 論,唯音的兩代引擎都比 gramambular2 早約四個月。
對小麥注音的 UOM:唯音的 POM 與小麥注音的 User Override Model 外形相近——鍵都是三格(ante/prev/head)的 (讀音, 詞值),標點一律視為句子邊界 ();情境的切分也相近(短換長/長換短/同長替換),並帶「強制高分」旗標;容量同為 500 筆的 LRU。但 POM 是刻意與 UOM 分道揚鑣的重寫:外形留著,分數語義與作用層級都換了。差別有三:
- 分數尺度:UOM 的分數是相對值——
count / total × exp((now − t)·λ),半衰期 1.5 小時,衰減至2^-20即歸零;它只用來在同一鍵的候選間排名。POM 的分數是絕對 log10,自 −0.1145 單調下探至 −13,視窗 8 日(急速遺忘 0.5 日);頻率只調曲線形狀(用得越多、下探越慢),不是乘上去的機率。唯其如此,POM 才能與語料側同尺度直接比較。 - 作用點:UOM 只回一個候選與一個旗標,由輸入控制器以 override 套用(
kOverrideValueWithHighScore或kOverrideValueWithScoreFromTopUnigram);POM 除建議之外,還把記憶物化成 bigram/trigram 元圖注入解碼,讓它在整句層級與語料競爭。 - 持久化:UOM 是行程內的單例,重啟即重學;POM 有 JSON 快照與追加式 WAL(CRC32、壓縮),跨啟動保留。
兩者的鍵都含前後文,故記憶都會隨語境碎裂;差別在 POM 把它當語言模型的一部分,UOM 只把它當候選窗的建議。兩者都不把使用者偏好寫回詞庫。
對酷音 v4 提案(2026-11-12 版):兩者對 bigram 的定位不同。該提案把 bigram 當成「主要準確率槓桿」,並以 Stupid Backoff 處理稀疏;唯音直接跳到 trigram,卻不建立 dense 的轉移矩陣——它只補會翻盤的列。前者是統計派的補齊,後者是策展派的稀疏化。兩者的共同點是「靜態模型與使用者狀態脫鉤」:酷音靠 static_lm.bin 與 user_freq.csv 分離,唯音靠「POM 只在解碼期疊加、從不寫回詞庫」。
對 Mozc/libpinyin:詞類 bigram 把轉移矩陣收斂到 |詞類|²,是 dense 路線的節流閥;唯音的詞類模板則是 sparse 路線的展開器——同一個語法直覺(「再」後接動詞、「在」後接處所名詞),一個用在轉移成本、一個用在列舉缺口。差別在資料量:唯音沒有足夠的語料去估一張詞類轉移矩陣,只能以模板列「宣告」語法事實,再逐條驗它是否翻盤。
對本文而言的參考文獻或被提及的文獻
外部
- Thorsten Brants, Ashok C. Popat, Peng Xu, Franz J. Och, Jeffrey Dean. Large Language Models in Machine Translation. EMNLP-CoNLL 2007, pp. 858–867.(Stupid Backoff:未正規化、固定 backoff 因子,適用只需 argmax 的解碼器;酷音 v4 提案(2026-11-12 版)之 bigram 回退依據)
- Roland Kuhn, Renato De Mori. A Cache-Based Natural Language Model for Speech Recognition. IEEE TPAMI 12(6):570–583, 1990.(線性插值把短期詞頻與 trigram 結合;唯音未採用,理由見〈背景〉之「詞頻是策展參數」)
- Reinhard Kneser, Hermann Ney. Improved Backing-off for M-gram Language Modeling. ICASSP 1995, vol. 1, pp. 181–184.(需正規化機率時的對照)
- Natalia Flerova, Radu Marinescu, Rina Dechter. Searching for the M Best Solutions in Graphical Models. JAIR 55:889–952, 2016.(m-A*;酷音解碼器
n_best_distinct之依據) - Kenneth Heafield. KenLM: Faster and Smaller Language Model Queries. WMT 2011, pp. 187–197.(n-gram 儲存成本;唯音之稀疏策略與此相反:不存 dense 表)
- Andreas Stolcke. Entropy-based Pruning of Backoff Language Models. DARPA BNTUW 1998.(相對熵剪枝;dense 模型若要上線,這是節流手段)
- 龔律全。《酷音輸入法技術報告》。2002。(三層架構與詞圖最大頻率路徑;本文〈歷史沿革〉之參照點)
- 新酷音輸入法架構更新提案(kanru、jserv、hiroshiyui;2026-11-12 版)。(unigram+bigram、m-A*、Stupid Backoff、cache LM 插值;本文末節之對照)
- McBopomofo/gramambular2。(純 unigram 介面、Viterbi、
kOverridingScore = 42;Source/Engine/UserOverrideModel.{h,cpp}為本文 UOM 對照之出處) - libchewing-rust(
_LABS/libchewing-rust)。(酷音現行 Rust 引擎;本文未以其為實作對象,僅作對照) - Chiahong Hong. Refactor DAG shortest path algorithm using Viterbi. McBopomofo PR #777, 2026-01。(把 gramambular2 的 DAG 最短路徑改寫為 Viterbi 動態規劃)
- McBopomofo 的 Viterbi 改寫。(此一改寫的說明)
- Daniel Jurafsky, James H. Martin. Speech and Language Processing, 3rd ed., Appendix A: Hidden Markov Models。(Viterbi 演算法與 HMM 的教科書推導)
原始碼
- vChewing-LibVanguard:
Sources/Homa/(Gram、Node、PathFinder)、Sources/TrieKit/、Sources/LexiconAssembly/。 - vChewing-VanguardLexicon:
Sources/Yukuaki/(含Advisor)、Sources/LibVanguardChewingData/(Collector_Ngram、DataBuilder_VanguardTextMap)、Sources/VanguardLexiconDebugger/、Sources/VanguardLexiconCorpusMiner/。 - MegrezNT:
Megrez/src/2_PathFinder.cs;本文 DAG-Vertex → Dijkstra → DAG-DP 時間線之出處。
授權
vChewing-LibVanguard 為 LGPL-3.0-or-later(附 Swift 靜態連結例外);vChewing-VanguardLexicon(含 Yukuaki)與 vChewing-macOS 之自研內容為 MulanPSL-2.0。本文所述之程式碼引用均依其來源授權。