上一篇最後,我提到自己因為開始重新思考 CAPT 的本質,所以決定重新訓練 V3。
結果 V3 跑完之後,我在辨識結果裡看到了一個沒看過的東西 —— [UNK]。
一開始看到它的時候,我其實覺得很奇怪。前面的訓練並沒有一直出現這個問題,為什麼到了 V3,結果突然開始出現 [UNK]?
如果只是一般的聲母、韻母或聲調辨識錯誤,至少還可以繼續分析模型到底把哪個音辨識成哪個音。但 [UNK] 不一樣,它比較像是模型連這個東西都不知道要對應到什麼。
我當時第一個懷疑的其實不是 Unicode,而是前面才剛處理過的變調。因為這次的 V3 是在前面開始處理多音節資料之後重新訓練的,而多音節資料又牽涉到前面的音節要不要變調。如果變調程式產生了模型詞彙表裡不存在的字串,確實有可能讓後面的處理出現問題。
所以我沒有馬上重新訓練,而是先回頭檢查產生出來的檔名,看看是不是有一些不符合預期的臺羅。
都怪我沒檢查🙏。
我在檢查 V3 的異常時,也回頭檢查前面多音節資料的變調處理,並重新整理自己當時寫進程式的變調規則:

這是當時實際做過的資料與程式檢查。整理的過程中,我也開始拿實際資料去比對,確認程式產生的變調結果是否符合我的預期。
然後……我檢查了一輪,[UNK] 還是在。
為什麼啦!到底怎麼回事啊!(懷疑人生)。
前面已經遇過 GPU 額度、搬回本機、硬碟空間、訓練時間,現在連資料的變調邏輯也重新檢查過了,結果這個 [UNK] 還是橫在眼前。只好繼續調查。
[UNK] 到底是什麼?[UNK] 通常是 tokenizer 裡的 Unknown Token(未知詞元)。簡單來說,模型在處理文字時,不是直接把整串文字丟進去,而是會先經過 tokenizer,把文字切成模型詞彙表裡可以辨識的 token,再轉換成模型可以處理的 token ID。
例如,假設 tokenizer 的詞彙表裡有:a、b、c、á,那麼遇到這些字元時,就可以找到對應的 token。
但當時我看到模型輸出 [UNK],還不能直接確定問題究竟發生在「Label → Tokenizer」這一段,還是模型最後真的預測出了 [UNK]。所以我只能先把它當成一個異常訊號,繼續往前追查。
到這邊我就疑惑,怎麼會出現 [UNK],到底是哪裡辨識不了,明明音檔名看起來好好的。後來我又遇到兩個完全沒聽過的東西:NFC 和 NFD。它們其實都是 Unicode 的「正規化(Normalization)」形式。
為了確認問題,我當時其實寫了幾支不同的檢查程式。先檢查 tokenizer 的詞表,再掃描整個訓練集的檔名,最後才把看起來一樣的台羅字串送進 tokenizer,比較它們真正得到的 Token ID。
那為什麼文字需要正規化?因為 Unicode 裡,有些文字可以用不只一種方式表示。最常見的情況,就是一個「帶有符號的字元」,可以直接使用一個已經組合好的字元,也可以拆成「基本字元+組合符號」。
例如 á,它可以直接表示成一個單獨字元:
á (U+00E1)
也可以表示成拆解的狀態:
a + ◌́ (U+0061 + U+0301)
人眼看到這兩種寫法,基本上都會認為它們是 á。但對電腦來說,底層的 Unicode code point 序列並不一樣。
其中,我這次最需要理解的是 NFC 和 NFD:
NFC(Normalization Form C):可以把它理解成**「組合版」**。它會盡可能把基本字元和組合符號組回一個字元:a + ◌́ → NFC → á
NFD(Normalization Form D):可以把它理解成**「拆解版」**,把已經組合好的字元拆成基本字元和組合符號:á → NFD → a + ◌́
所以可以先簡單記成:
這時候我才發現,**「看起來一樣」跟「電腦底層完全一樣」其實是兩回事。**而這件事情對我的台羅資料特別重要,因為台羅裡有大量聲調符號。如果同一個字的聲調符號有不同 Unicode 表示方式,模型前面的 tokenizer 就可能收到不同的字元序列。
我實際檢查資料後,真的發現了這個問題。例如實驗中,我拿 tá 進行 Unicode 分析,得到:
U+0074 U+00E1
U+0074 U+0061 U+0301
更重要的是,我進一步把它們送進 tokenizer,得到的 Token ID 也不一樣。這個結果讓我第一次很直觀地看到,人眼看到的「同一個字」,不代表 tokenizer 看到的是同一個輸入。
tá 在不同 Unicode representation 下,最後得到的 Token ID 序列也不同:
[25, 28]
[25, 11, 60]
這些檢查不是我一開始就規劃好的流程,而是看到 [UNK] 之後,一支一支補出來的。先確認詞表,再看整批資料,最後才找到這個肉眼看不見的差異。
如果你也想親眼看看這個 Bug 長什麼樣子,我把這段驗證整理成簡化後的公開重現版,放在 GitHub 的 Day 7 資料夾中。
你只要利用 Python 內建的 unicodedata 模組,搭配 Hugging Face 的 Tokenizer,就能自己比較 NFC、NFD 和 Token ID 的差異:
→ GitHub 實作範例:https://github.com/yifyu1122/taiwanese-asr-lab
這個小實驗不會直接證明所有 [UNK] 都是 Unicode 造成的,但至少讓我確認了一件事:Unicode representation 並不是單純的文字顯示問題,它真的會改變 tokenizer 收到的輸入。
而我的資料檢查也顯示,這不是只有一兩筆特殊資料:在當時統計的 9,791 個音檔中,NFC 與 NFD 的資料都有出現,並且有 1,545 筆資料被判定需要進行 NFC 修正。
結果找到 Unicode 之後,問題就算解決了嗎?好像也沒有那麼快。
我知道資料裡有不同的 Unicode 表示方式,也知道 tokenizer 真的會把它們看成不同的輸入,但接下來要怎麼修正、修正之後模型會不會變好,還要重新整理資料再驗證一次。
剩下的結果,我們明天再看。
我是微微,大家再見!