layout: blog
title: Bert系列伴生的新分詞器
date: 2020-04-29 09:31:52
tags: 5
categories: nlp
mathjax: true
typora-root-url: ..
本博客選自https://dxzmpk.github.io/,如果想了解更多關於transformers模型的使用問題,請訪問博客源地址。
概括
這篇文章將對Bert等模型使用的分詞技術進行介紹。同時會涉及這些分詞器在huggingface tokenizers庫中的使用。理解這些分詞器的原理,對於靈活使用transformers庫中的不同模型非常重要。除此之外,我們還能將這些分詞器用於其他任務中,如果有必要的話,我們還能自己訓練分詞器。
分詞器是做什么的?
機器無法理解文本。當我們將句子序列送入模型時,模型僅僅能看到一串字節,它無法知道一個詞從哪里開始,到哪里結束,所以也不知道一個詞是怎么組成的。
所以,為了幫助機器理解文本,我們需要
- 將文本分成一個個小片段
- 然后將這些片段表示為一個向量作為模型的輸入
- 同時,我們需要將一個個小片段(token) 表示為向量,作為詞嵌入矩陣, 通過在語料庫上訓練來優化token的表示,使其蘊含更多有用的信息,用於之后的任務。
古典分詞方法
基於空格的分詞方法
一個句子,使用不同的規則,將有許多種不同的分詞結果。我們之前常用的分詞方法將空格作為分詞的邊界。也就是圖中的第三種方法。但是,這種方法存在問題,即只有在訓練語料中出現的token才能被訓練器學習到,而那些沒有出現的token將會被<UNK>
等特殊標記代替,這樣將影響模型的表現。如果我們將詞典做得足夠大,使其能容納所有的單詞。那么詞典將非常龐大,產生很大的開銷。同時對於出現次數很少的詞,學習其token的向量表示也非常困難。除去這些原因,有很多語言不用空格進行分詞,也就無法使用基於空格分詞的方法。綜上,我們需要新的分詞方法來解決這些問題。
基於字母的分詞方法
簡單來說,就是將每個字符看作一個詞。
優點: 不用擔心未知詞匯,可以為每一個單詞生成詞嵌入向量表示。
缺點:
- 字母本身就沒有任何的內在含義,得到的詞嵌入向量缺乏含義。
- 計算復雜度提升(字母的數目遠大於token的數目)
- 輸出序列的長度將變大,對於Bert、CNN等限制最大長度的模型將很容易達到最大值。
基於子詞的分詞方法(Subword Tokenization)
為了改進分詞方法,在<UNK>
數目和詞向量含義豐富性之間達到平衡,提出了Subword Tokenization方法。這種方法的目的是通過一個有限的單詞列表來解決所有單詞的分詞問題,同時將結果中token的數目降到最低。例如,可以用更小的詞片段來組成更大的詞:
“unfortunately” = “un” + “for” + “tun” + “ate” + “ly”。
接下來,將介紹幾種不同的Subword Tokenization方法。
Byte Pair Encoding (BPE) 字節對編碼
概述
字節對編碼最早是在信號壓縮領域提出的,后來被應用於分詞任務中。在信號壓縮領域中BPE過程可視化如下:
接下來重點介紹將BPE應用於分詞任務的流程:
實現流程
- 根據語料庫建立一個詞典,詞典中僅包含單個字符,如英文中就是a-z
- 統計語料庫中出現次數最多的字符對(詞典中兩項的組合),然后將字符對加入到詞典中
- 重復步驟2直到到達規定的步驟數目或者詞典尺寸縮小到了指定的值。
BPE的優點
可以很有效地平衡詞典尺寸和編碼步驟數(將句子編碼所需要的token數量)
BPE存在的缺點:
- 對於同一個句子, 例如Hello world,如圖所示,可能會有不同的Subword序列。不同的Subword序列會產生完全不同的id序列表示,這種歧義可能在解碼階段無法解決。在翻譯任務中,不同的id序列可能翻譯出不同的句子,這顯然是錯誤的。
- 在訓練任務中,如果能對不同的Subword進行訓練的話,將增加模型的健壯性,能夠容忍更多的噪聲,而BPE的貪心算法無法對隨機分布進行學習。
Unigram Based Tokenization
方法概述
分詞中的Unigram模型是Kudo.在論文“Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates”中提出的。當時主要是為了解決機器翻譯中分詞的問題。作者使用一種叫做marginalized likelihood
的方法來建模翻譯問題,考慮到不同分詞結果對最終翻譯結果的影響,引入了分詞概率$P(\vec{x}|X)$來表示$X$最終分詞為$\vec{x}$的概率(X為原始的句子, $\vec{x}$為分詞的結果$\vec{x} = (x_1, . . . , x_M) $,由多個subword組成)。傳統的BPE算法無法對這個概率進行建模,因此作者使用了Unigram語言模型來達到這樣的目的。
方法執行過程
假設:根據unigram的假設,每個字詞的出現是獨立的。所以
$$
P(\vec{x}) = \prod_{i=1}^{M}p(x_i)
$$
這里的$x_i$是從預先定義好的詞典$V$中取得的,所以,最有可能的分詞方式就可以這樣表示:
$$
x^* =\underset{x\in S(X)}{arg;max};P(\vec{x})
$$
這里$S(X)$是句子$X$不同的分詞結果集合。$x^*$可以通過維特比算法得到。
如果已知詞典$V$, 我們可以通過EM算法來估計$p(x_i)$,其中M步最大化的對象是以下似然函數(原諒我這里偷懶直接使用圖片):
$|D|$是語料庫中語料數量。
我是這樣理解這個似然函數的:將語料庫中所有句子的所有分詞組合形成的概率相加。
初始時,我們連詞典$V$都沒有,作者通過不斷執行以下步驟來構造合適的詞典以及分詞概率:
-
從頭構建一個相當大的種子詞典。
-
重復以下步驟,知道字典尺寸$|V|$減小到期望值:
-
固定詞典,通過EM算法優化$p(x)$
-
為每一個子詞計算$loss_i$,loss代表如果將某個詞去掉,上述似然函數值會減少多少。根據loss排序,保留loss最高的$\eta$個子詞。注意:保留所有的單字符,從而避免OOV情況。
我是這樣理解loss的:若某個子詞經常以很高的頻率出現在很多句子的分詞結果中,那么其損失將會很大,所以要保留這樣的子詞。
-
主要貢獻:
- 使用的訓練算法可以利用所有可能的分詞結果,這是通過data sampling算法實現的。
- 提出一種基於語言模型的分詞算法,這種語言模型可以給多種分詞結果賦予概率,從而可以學得其中的噪聲。
將基於子詞的分詞方法應用到實際中
Bert中的WordPiece分詞器
WordPiece是隨着Bert論文的出現被提出的。在整體步驟上,WordPiece方法和BPE是相同的。即也是自低向上地構建詞典。區別是BPE在每次合並的時候都選擇出現次數最高的字符對,而WordPiece使用的是類似於Unigram的方法,即通過語言模型來得到合並兩個單詞可能造成的影響,然后選擇使得似然函數提升最大的字符對。這個提升是通過結合后的字符對減去結合前的字符對之和得到的。也就是說,判斷“de”相較於“d”+"e"是否更適合出現。
三種分詞器的關系如下:(圖自FloudHub Blog)
SentencePiece庫
SentencePiece是在“SentencePiece: A simple and language independent subword tokenizer
and detokenizer for Neural Text Processing”這篇文章中介紹的。其主要是為了解決不同語言分詞規則需要特別定義的問題,比如下面這種情況:
Raw text: Hello world.
Tokenized: [Hello] [world] [.]
Decoded text: Hello world .
將分詞結果解碼到原來的句子中時,會在不同的詞之間添加空格,生成Decoded text
所示的結果,這就是編碼解碼出現的歧義性,因此需要特別定義規則來實現互逆。還有一個例子是,在解碼階段,歐洲語言詞之間要添加空格,而中文等語言則不應添加空格。對於這種區別,也需要單獨定制規則,這些繁雜的規則維護起來非常困難,所以作者采用以下的方案來解決:
將所有的字符都轉化成Unicode編碼,空格用‘_’來代替,然后進行分詞操作。這樣空格也不需要特別定義規則了。然后在解碼結束后,使用Python代碼恢復即可:
detok = ’’.join(tokens).replace(’_’, ’ ’)
SentencePiece庫主要由以下部分組成:
“Normalizer, Trainer, Encoder, Decoder”
其中Normalizer用來對Unicode編碼進行規范化,這里使用的算法是NFKC
方法,同時也支持自定義規范化方法。Trainer則用來訓練分詞模型。Encoder是將句子變成編碼,而Decoder是反向操作。他們之間存在以下函數關系:
$$
Decode(Encode(Normalize(text))) = Normalize(text):
$$
Huggingface tokenizers庫的介紹和使用
tokenizers是集合了當前最常用的分詞器集合,效率和易用性也是其關注的范疇。
使用示例:
# Tokenizers provides ultra-fast implementations of most current tokenizers:
>>> from tokenizers import (ByteLevelBPETokenizer,
CharBPETokenizer,
SentencePieceBPETokenizer,
BertWordPieceTokenizer)
# Ultra-fast => they can encode 1GB of text in ~20sec on a standard server's CPU
# Tokenizers can be easily instantiated from standard files
>>> tokenizer = BertWordPieceTokenizer("bert-base-uncased-vocab.txt", lowercase=True)
# Tokenizers provide exhaustive outputs: tokens, mapping to original string, attention/special token masks.
# They also handle model's max input lengths as well as padding (to directly encode in padded batches)
>>> output = tokenizer.encode("Hello, y'all! How are you 😁 ?")
>>> print(output.ids, output.tokens, output.offsets)
[101, 7592, 1010, 1061, 1005, 2035, 999, 2129, 2024, 2017, 100, 1029, 102]
['[CLS]', 'hello', ',', 'y', "'", 'all', '!', 'how', 'are', 'you', '[UNK]', '?', '[SEP]']
[(0, 0), (0, 5), (5, 6), (7, 8), (8, 9), (9, 12), (12, 13), (14, 17), (18, 21), (22, 25), (26, 27),(28, 29), (0, 0)]
# Here is an example using the offsets mapping to retrieve the string corresponding to the 10th token:
>>> output.original_str[output.offsets[10]]
'😁'
自己訓練分詞器
# You can also train a BPE/Byte-levelBPE/WordPiece vocabulary on your own files
>>> tokenizer = ByteLevelBPETokenizer()
>>> tokenizer.train(["wiki.test.raw"], vocab_size=20000)
[00:00:00] Tokenize words ████████████████████████████████████████ 20993/20993
[00:00:00] Count pairs ████████████████████████████████████████ 20993/20993
[00:00:03] Compute merges ████████████████████████████████████████ 19375/19375
參考材料
這篇文章是在Floydhub的一篇博客基礎上擴展的。還主要參考了Unigram的原論文,BPE的官方解釋等。BPE的動態圖來自於Toward data science的有關博客。除此之外,最后一章參考於tokenizers的官方倉庫。