iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0

對,昨天爬了一下文,發現有點嚴重的事,稍微打亂了計劃?!

原本預計今天要進到 LLM 內部,結果在想今天要寫的內容時,滑了一下大神整理的文章,以及 Gemma 模型的報告,間接暗指 Gemini 的分詞器有可能是用 Unigram 訓練的。老實說,我自己還沒找到 Gemini 或是 Gemma 的分詞器是用 BPE 還是 Unigram 訓練的直接證據,只有看到他們都說是用 SentencePiece 這套工具來當分詞器,而 SentencePiece 的 model_type 預設值 就是用 Unigram 來訓練分詞器的。

"We use the same tokenizer as Gemini Team (2023), that is, a SentencePiece tokenizer (Kudo and Richardson, 2018) with split digits, preserved whitespace, and byte-level encodings. The vocabulary has 256k entries." - Gemma Technical Report (2024)

我們的主題既然是「Build on Google AI」,事已至此,不講講 Unigram 好像說不太過去。所以今天緊急加開番外篇,稍微也來了解一下 Unigram 分詞演算法吧(我同時也是在對我自己說)!這樣至少不管 Gemini 的分詞器是用什麼方法建立的,都在我們的認知範圍內!


1. Unigram 與 BPE 的哲學碰撞

在前兩天我們聊到分詞器的 BPE(Byte-Pair Encoding),本質上是一種 Bottom-up 策略。一開始字典非常薄,只有基礎字母和 256 個 UTF-8 Byte。演算法統計訓練語料中誰最常相鄰出現,就把誰合併成新詞,直到這本字典的詞彙量達到預期的字典大小。

而 SentencePiece 預設採用的 Unigram,思考哲學則是反過來的 Top-down 思維。一開始從語料庫先窮舉龐大的「候選池」(通常有幾十萬個可能的子字串)。接著,透過統計學評估 「如果把這個詞從字典裡拔除,會對整篇語料庫的編碼表示造成多大損失?」 把那些可有可無、冗餘、對理解貢獻偏低的碎片一輪輪淘汰掉,直到收斂成設定的目標字典大小。

聽起來很複雜,也確實有點複雜,我今天研究了一個晚上才大概懂一點皮毛。由於整個過程很數學,我想挑戰用很少量的數學來解釋看看,請為我加油~


2. 訓練結果:開箱詞表 .vocab

先再次讚嘆 SentencePiece 一下,這個套件要使用什麼方式訓練的機制已經處理好了,我們只需要把 model_type 設定成 unigram 就好了(也可以不寫,model_type預設值 就會是 unigram),如下:

import sentencepiece as spm

spm.SentencePieceTrainer.train(
    input="data/corpus.txt",
    model_prefix="models/model",
    vocab_size=5000,
    model_type="unigram",
    character_coverage=1.0,
    byte_fallback=True,
    remove_extra_whitespaces=False,
)

我們一樣用昨天的「Google 維基百科繁體中文條目」當訓練語料,訓練完打開 SentencePiece 所產生的 .vocab 檔案,這次我們會長這樣,例如:

<unk>   0
<s>     0
</s>    0
<0x00>	0
<0x01>	0
...
<0xFE>	0
<0xFF>	0
,	-3.09985
Google	-3.60513
。	-3.76791
的	-3.79702
年	-4.17588
月	-4.65983
▁	-4.68046
公司	-4.89723
...

最前面的部分依然是特殊標記跟 256 個 Byte 表示,接著就是這些訓練好的 Token,但後面那個負數是什麼呢?(警告:燒腦的部分來了)

這些數值是這個詞在訓練語料庫中出現的機率(Probability, P)然後取自然對數(ln),因為機率一定介於 0 到 1 之間,所以取對數後一定是負數!所以「 數值越小(負越多),代表出現機率越小,在整篇文章越罕見,出現頻率越低」。例如 -4.89723-3.60513 小,代表「公司」出現的頻率在訓練語料中是低於「Google」這個字的。

為什麼不直接存機率,而是取對數?
因為如果一句話有 20 個 Token,每個機率是 0.01,電腦必須計算連續乘 0.01 乘 20 次,大約會到 $10^{-40}$。這麼小的浮點數應該會直接 Underflow 變成 0。而取對數後,乘法就直接變成了加法,這樣就不會失真了。


3. EM 演算法:最初的機率哪裡來?(警告:此後篇幅很燒腦,只想看結論可以直接跳到第 6 節)

雖然一開始我們窮舉出很多候選詞,但候選池裡的 Token 大多是互相重疊的。面對一整坨沒分詞的句子,在不知道句子該怎麼切的情況下,我們要怎麼公平地統計詞頻來算機率呢?

這裡會用到 EM 演算法(Expectation-Maximization) 來估算每個詞的出現頻率,過程就是:「猜一次、算一次、反覆校正!」

  • E-Step(算期望值):用目前的機率評估各種切法的可能性,把出現次數「按比例分配」給各個詞。
  • M-Step(更新機率):把剛才分到的次數加總,重新算出更準確的新機率。

實例演練一次:

假設語料庫只有一句話:「搜尋引擎」,初始候選池有 3 個 Token:["搜尋引擎", "搜尋", "引擎"]
這句話只有兩種切分可能:長詞 ["搜尋引擎"]雙字 ["搜尋", "引擎"]。(註:真實語料庫中還有千千萬萬包含「搜尋」或「引擎」的其他句子,如「搜尋資料」、「飛機引擎」,因此雙字在全局中也會累積龐大頻率。這裡為了用最精簡的數學說清楚 EM 原理,我們簡化以單一句子示範單輪更新機制)

Token 初始猜測 (Iter 0) 第 1 次迭代 (Iter 1) 第 2 次迭代 (Iter 2) 第 3 次迭代 (收斂)
搜尋引擎 33%(均勻瞎猜) 60% 88.7% ~98%(大贏家)
搜尋 33%(均勻瞎猜) 20% 5.7% ~1%
引擎 33%(均勻瞎猜) 20% 5.7% ~1%
  • Iter 0(瞎猜):大家都是 33%。雙字切法機率為 $0.33 * 0.33 ~= 0.11$,長詞切法為 0.33。長詞佔了約 75% 信心(0.33 / (0.11+0.33)),於是次數被瓜分成:長詞 0.75 次;而雙字切法佔 0.25 次(代表『搜尋』與『引擎』各分到 0.25 次)。
  • Iter 1(首輪更新)
    我們透過 「自己的次數 / 大家的總次數」 計算新機率:
    • 分配到的總次數 = $0.75 (長詞) + 0.25 (搜尋) + 0.25 (引擎) = 1.25$ 次
    • 搜尋引擎 新機率 = 0.75 / 1.25 = 60%
    • 搜尋 新機率 = 0.25 / 1.25 = 20%
    • 引擎 新機率 = 0.25 / 1.25 = 20%
  • Iter 2~3(持續收斂):持續拿新機率再算一次切法信心,可以發現只要 3 輪,長詞的信心就達到 98%,機率就接近收斂狀態。

這裡期望值 0.75 的物理意義是什麼?
就是:上述例子這輪 「Token 的出現頻率算你 0.75 次!」 平常計算詞的出現頻率是看到就 +1 次;但在未標註文本中,算出來切成「搜尋引擎」的發生機率佔了 75%。此時不敢武斷給 1 整次,那就按這 75% 的機率折算,算它的出現頻率為 0.75 次。這樣一來,我們就能在未分詞的文本中統計出每個 Token 的「期望出現頻率」,進而算出真實的機率分佈,為接下來的「淘汰 Token」提供評估依據。


4. Unigram 淘汰賽:怎麼挑出最後目標固定數量個詞?(例如:5000)

這裡要特別提:不是「淘汰頻率低的詞」。演算法的概念是想知道:「如果把這個詞硬生生拔掉,整個語料庫的成本會增加多少(損失會變多大)?」

我們剛剛透過 EM 算好每個 Token「期望出現機率」,還記得最一開始我們有說這裡會將機率取自然對數,在這裡我們會再將機率 轉成「成本」的概念(也就是取負對數 ln P,把原本取對數後的負值加個負號轉成正數),效果就是:「機率越小 -> 對數值越負 -> 負對數(成本)越大」

我們延續剛才 「搜尋引擎」 這句話來討論(假設詞表各詞成本為:長詞 搜尋引擎 3.9、雙字 搜尋 2.3、引擎 3.0、單字 各 6.9):

  • 討論一:試著刪除【搜尋】
    如果拔掉「搜尋」,原本雙字切法 ["搜尋", "引擎"] 就破滅了,被迫退化成碎片 ["搜", "尋", "引擎"],成本從 5.3 上升到 16.8。
    -> 損失增加 Loss = 16.8 - 5.3 = +11.5。拔掉它會讓模型損失較大,代表它重要,所以傾向不能刪!
  • 討論二:試著刪除【搜尋引擎】
    原本長詞 ["搜尋引擎"] 成本是 3.9;拔掉它之後,模型退而求其次切成現有的雙字 ["搜尋", "引擎"],成本只微增到 5.3。
    -> 損失增加 Loss = 5.3 - 3.9 = +1.4。拔掉它模型損失較小,現有詞依然拼得很好,代表它可以優先淘汰!
    (而且把它刪掉後,原本整句被它吃走的「搜尋」與「引擎」就能重新算次數。只要「搜尋」與「引擎」出現次數變多、機率變高,其他有單獨用到「搜尋」(如「搜尋資料」)或「引擎」(如「飛機引擎」)的句子,成本就會跟著下降!)

Unigram 就是這樣,每一輪計算每個詞的「疼痛指數(Loss)」,把最不痛的倒數 10%~20% 詞彙砍掉,循環直到縮減至目標大小(例如:5000)。

這也解釋了「為什麼整篇 5,000 字文章不會變成 1 個 Token」
除了原始碼中 max_sentencepiece_length = 16 的硬限制外,整篇文章通常只出現 1 次,拔掉它改用基礎詞拼湊,損失增加趨近於 0,很容易在第一輪就被淘汰!


5. 推論階段:Viterbi 演算法

模型訓練好後,詞表(.vocab)已被建立好,每個詞的成本都已固定,EM 演算法與 Unigram 的任務就結束了!

現在進入推論階段。當用戶輸入一句話:「搜尋引擎」,分詞器使用 Viterbi 演算法,就像求最短路徑一樣,比對所有可能路線的總累積成本找成本最少的那一個:

  • 路線 A(切成碎單字)["搜", "尋", "引", "擎"] -> 總成本 = 6.9 * 4 = 27.6
  • 路線 B(切成複合長詞)["搜尋引擎"] -> 總成本 = 3.9
  • 路線 C(切成兩個雙字詞)["搜尋", "引擎"] -> 總成本 = 2.3 + 3.0 = 5.3

因為「路線 B (3.9) < 路線 C (5.3) < 路線 A (27.6)」,拿總成本最低的路線 B(["搜尋引擎"])作為最佳輸出!

由於 Viterbi 演算法是一種 DP 演算法,所以相較於 BPE 的 Greedy 策略,Unigram 透過 Viterbi 實現整句話的「全局最佳解」。

(看到這個篇幅就知道我有多不想講這個演算法,相信有很多大神解釋過,歡迎大家自行搜尋)


6. 結論

演算法 什麼時候出場? 具體任務
EM 演算法 訓練階段(離線) 在不知道真實切分的情況下,反覆估算並收斂出每個 Token 的真實機率與成本。
Unigram 剪枝(Pruning) 訓練階段(離線) 計算「拔掉誰之後整體損失增加最少」,把冗餘詞淘汰,收斂到指定詞表大小。
Viterbi 演算法 推論階段(線上) 拿著已經訓練好的成本數值,用最短路徑動態規劃,即時找出總成本最低的最優切法。

回過頭來看,雖然我目前還是無法 100% 確定 Gemini 官方最終定案是用 BPE 還是 Unigram,但今天多看了這一層,其實收穫蠻大的。

不管是 OpenAI、Llama 偏愛的 BPE,還是 SentencePiece 預設且可能被 Google 採用的 Unigram,兩種演算法最終目的,都是想把無窮無盡的文本,盡可能收斂成一本字典,讓模型能夠讀懂我們所使用的語言,儘管思考哲學非常不一樣。有了這層認知,不管未來官方進一步公開的報告證實它偏向哪一個流派,我們對底層分詞的黑盒都不再陌生。

該睡了,明天見~


參考資料


上一篇
Day 05 | 少年 Token(下):從 Token 到 Embedding
下一篇
Day 07 | 少年 Token 的奇幻漂流(上):為什麼現代 LLM 都是 Decoder-Only?
系列文
在贏家書寫歷史之前:我所看見的 AI,與一位工程師共舞著10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言