[NLP]——BPE、WordPiece、Unigram and SentencePiece
目錄
- 前言
 - Byte Pair Encoding(BPE)
 - WordPiece
 - Unigram
 
前言
單詞級別的tokenizer有以下缺點:
- 單詞變體算做不同單詞,無法體現它們的關聯
 
本文從代碼層次解析四種常用的tokenizer(放棄了)
Byte Pair Encoding(BPE)
提出論文:Neural machine translation of rare words with subword units
以下講解基本參考:Byte Pair Encoding
假設擁有一個含有很多單詞的語料,首先統計各個單詞出現的次數,以單詞字符串為鍵、次數為值。預測同時,要對單詞字符串進行兩個操作:1. 首先給這個單詞字符串后面加上一個結束符號"</w>", 2. 然后把單詞字符串分成一個一個的字符。
比如我們有一個語料:['low','lower','newest','widest']
 經過上述操作得到:{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w e s t </w>': 6, 'w i d e s t </w>': 3}
然后正試開始迭代,每一次迭代,遍歷上述字典的所有鍵,把字符串按照空格分割成字符序列,統計所有字符對出現的次數,比如'es'這個字符對出現了6+3=9次,而它也是出現次數最多的,所以將它合并。
 這是第一次迭代的結果:{'l o w </w>': 5, 'l o w e r </w>': 2, 'n e w es t </w>': 6, 'w i d es t </w>': 3}
 接下去的迭代一摸一樣。需要注意的是,在第一次迭代以前,'e'和's'都作為單個token出現在語料中,在第一次迭代以后,所有的's'都通過合并而消失了,而由于l o w e r中還有'e',所以'e'依然作為一個token存在。另外,別忘了,新增了一個'es'token,總token數不變。
 那么很容易想到每一輪的token數變化情況,有以下四種(假設當前輪是要去合并a和b這兩個token):
- token數不變 
- 所有的a都出現在b前面,合并之后a消失;而b前面不僅僅出現過a,合并之后b依然存在
 - 所有的b都出現在a后面,合并之后b消失;而a后面不僅僅出現過b,合并之后a依然存在
 
 - token數-1:a和b僅以對的形式出現,合并之后a和b消失,新增一個ab,總數-1
 - token數+1:a后面不僅僅出現過b,同樣的,b前面也不僅僅出現過a,原來token數不變,又新增一個ab,總數+1
 
token總數可能呈現這樣的變化趨勢:一開始的token出現形式多樣,token總數會上升;隨著不斷地迭代合并,token數量增加,但token出現的形式減少,token總數會慢慢減少。
下面是代碼部分:
import re, collectionsdef get_vocab(corpus):vocab = collections.defaultdict(int)for word in corpus:vocab[' '.join(list(word)) + ' </w>'] += 1return vocabdef get_stats(vocab):pairs = collections.defaultdict(int)for word, freq in vocab.items():symbols = word.split()for i in range(len(symbols)-1):pairs[symbols[i],symbols[i+1]] += freqreturn pairsdef merge_vocab(pair, v_in):v_out = {}bigram = re.escape(' '.join(pair))p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)')for word in v_in:w_out = p.sub(''.join(pair), word)v_out[w_out] = v_in[word]return v_outdef get_tokens(vocab):tokens = collections.defaultdict(int)for word, freq in vocab.items():word_tokens = word.split()for token in word_tokens:tokens[token] += freqreturn tokenscorpus = ['low','lower','newest','widest'] vocab = get_vocab(corpus) print('==========') print('Tokens Before BPE') tokens = get_tokens(vocab) print('Tokens: {}'.format(tokens)) print('Number of tokens: {}'.format(len(tokens))) print('==========')num_merges = 3 for i in range(num_merges):pairs = get_stats(vocab)if not pairs:breakbest = max(pairs, key=pairs.get)vocab = merge_vocab(best, vocab)print('Iter: {}'.format(i))print('Best pair: {}'.format(best))tokens = get_tokens(vocab)print('Tokens: {}'.format(tokens))print('Number of tokens: {}'.format(len(tokens)))print('==========')輸出:
========== Tokens Before BPE Tokens: defaultdict(<class 'int'>, {'l': 2, 'o': 2, 'w': 4, '</w>': 4, 'e': 4, 'r': 1, 'n': 1, 's': 2, 't': 2, 'i': 1, 'd': 1}) Number of tokens: 11 ========== Iter: 0 Best pair: ('l', 'o') Tokens: defaultdict(<class 'int'>, {'lo': 2, 'w': 4, '</w>': 4, 'e': 4, 'r': 1, 'n': 1, 's': 2, 't': 2, 'i': 1, 'd': 1}) Number of tokens: 10 ========== Iter: 1 Best pair: ('lo', 'w') Tokens: defaultdict(<class 'int'>, {'low': 2, '</w>': 4, 'e': 4, 'r': 1, 'n': 1, 'w': 2, 's': 2, 't': 2, 'i': 1, 'd': 1}) Number of tokens: 10 ========== Iter: 2 Best pair: ('e', 's') Tokens: defaultdict(<class 'int'>, {'low': 2, '</w>': 4, 'e': 2, 'r': 1, 'n': 1, 'w': 2, 'es': 2, 't': 2, 'i': 1, 'd': 1}) Number of tokens: 10 ==========說明幾個python相關:
- collections.defaultdict() 跟dict的區別就是,不存在的鍵也可以直接加進去
 - re.escape() 把所有可能是正則的符號進行轉義
 - re.compile(r’(?<!\S)’ + bigram + r’(?!\S)’) 首尾不是非空字符–>首尾要么啥也沒有,如果有,只能是空字符。比如要合并e和s,如果不加這一句,'e sd’會被合并成 ‘esd’
 - best = max(pairs, key=pairs.get) 以字典中的值為關鍵字,選擇值(出現次數)最大的鍵(token對)
 
接下來是解碼和編碼。所謂解碼,就是把一個subword序列,拼回一個string,比如[“the</w>”, “high”, “est</w>”, “moun”, “tain</w>”]—解碼—>the</w> highest</w> mountain</w>。這好做,下面將編碼。
 所謂編碼,就是把一個string轉換成subword序列,具體步驟為: 把迭代完的token集按照長度排序,長的在前,對于每一個string,從大大小遍歷token集,一旦發現一個token出現在這個string中一次或者多次,出現這個token的地方保留下來,其它地方遞歸查詢。特別的,假設目前匹配到的token在這個token集中的100個,那么string的其它地方只需要在第101開始的更短的token集中進行搜索。如果當前遞歸層的string,遍歷了所有的token都沒有任何匹配,就把它轉成一個unknown token(’</u>‘)
下面是代碼部分:
import re, collectionsdef get_vocab(corpus):vocab = collections.defaultdict(int)for word in corpus:vocab[' '.join(list(word)) + ' </w>'] += 1return vocabdef get_stats(vocab):pairs = collections.defaultdict(int)for word, freq in vocab.items():symbols = word.split()for i in range(len(symbols)-1):pairs[symbols[i],symbols[i+1]] += freqreturn pairsdef merge_vocab(pair, v_in):v_out = {}bigram = re.escape(' '.join(pair))p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)')for word in v_in:w_out = p.sub(''.join(pair), word)v_out[w_out] = v_in[word]return v_outdef get_tokens_from_vocab(vocab):tokens_frequencies = collections.defaultdict(int)vocab_tokenization = {}for word, freq in vocab.items():word_tokens = word.split()for token in word_tokens:tokens_frequencies[token] += freqvocab_tokenization[''.join(word_tokens)] = word_tokensreturn tokens_frequencies, vocab_tokenizationdef measure_token_length(token):if token[-4:] == '</w>':return len(token[:-4]) + 1else:return len(token)def tokenize_word(string, sorted_tokens, unknown_token='</u>'):# Ilikeeatingapplesif string == '':return []if sorted_tokens == []:return [unknown_token]string_tokens = []flag = 0for i in range(len(sorted_tokens)):token = sorted_tokens[i]token_reg = re.escape(token.replace('.', '[.]'))matched_positions = [(m.start(0), m.end(0)) for m in re.finditer(token_reg, string)]if len(matched_positions) == 0:continueflag = 1substring_end_positions = [matched_position[0] for matched_position in matched_positions]substring_start_position = 0for substring_end_position in substring_end_positions:substring = string[substring_start_position:substring_end_position]string_tokens += tokenize_word(string=substring, sorted_tokens=sorted_tokens[i+1:], unknown_token=unknown_token)string_tokens += [token]substring_start_position = substring_end_position + len(token)remaining_substring = string[substring_start_position:]string_tokens += tokenize_word(string=remaining_substring, sorted_tokens=sorted_tokens[i+1:], unknown_token=unknown_token)breakif flag == 0:return [unknown_token]return string_tokenscorpus = ['low','lower','newest','widest']vocab = get_vocab(corpus) print('==========') print('Tokens Before BPE') tokens_frequencies, vocab_tokenization = get_tokens_from_vocab(vocab) print('All tokens: {}'.format(tokens_frequencies.keys())) print('Number of tokens: {}'.format(len(tokens_frequencies.keys()))) print('==========')num_merges = 10 for i in range(num_merges):pairs = get_stats(vocab)if not pairs:breakbest = max(pairs, key=pairs.get)vocab = merge_vocab(best, vocab) # print('Iter: {}'.format(i)) # print('Best pair: {}'.format(best))tokens_frequencies, vocab_tokenization = get_tokens_from_vocab(vocab) # print('All tokens: {}'.format(tokens_frequencies.keys())) # print('Number of tokens: {}'.format(len(tokens_frequencies.keys()))) # print('==========')# Let's check how tokenization will be for a known word word_given_known = 'newest</w>' word_given_unknown = 'Ilikeeatingapples!</w>'sorted_tokens_tuple = sorted(tokens_frequencies.items(), key=lambda item: (measure_token_length(item[0]), item[1]), reverse=True) sorted_tokens = [token for (token, freq) in sorted_tokens_tuple]print(sorted_tokens) print(vocab_tokenization) word_given = word_given_known print('Tokenizing word: {}...'.format(word_given)) if word_given in vocab_tokenization:print('Tokenization of the known word:')print(vocab_tokenization[word_given])print('Tokenization treating the known word as unknown:')print(tokenize_word(string=word_given, sorted_tokens=sorted_tokens, unknown_token='</u>')) else:print('Tokenizating of the unknown word:')print(tokenize_word(string=word_given, sorted_tokens=sorted_tokens, unknown_token='</u>'))word_given = word_given_unknown print('Tokenizing word: {}...'.format(word_given)) if word_given in vocab_tokenization:print('Tokenization of the known word:')print(vocab_tokenization[word_given])print('Tokenization treating the known word as unknown:')print(tokenize_word(string=word_given, sorted_tokens=sorted_tokens, unknown_token='</u>')) else:print('Tokenizating of the unknown word:')print(tokenize_word(string=word_given, sorted_tokens=sorted_tokens, unknown_token='</u>'))輸出(有個問題:’</w>'會被匹配到,但在大規模語料上訓練了,應該不會有這個問題):
========== Tokens Before BPE All tokens: dict_keys(['l', 'o', 'w', '</w>', 'e', 'r', 'n', 's', 't', 'i', 'd']) Number of tokens: 11 ========== ['lower</w>', 'est</w>', 'low</w>', 'ne', 'w', 'i', 'd'] {'low</w>': ['low</w>'], 'lower</w>': ['lower</w>'], 'newest</w>': ['ne', 'w', 'est</w>'], 'widest</w>': ['w', 'i', 'd', 'est</w>']} Tokenizing word: newest</w>... Tokenization of the known word: ['ne', 'w', 'est</w>'] Tokenization treating the known word as unknown: ['ne', 'w', 'est</w>'] Tokenizing word: Ilikeeatingapples!</w>... Tokenizating of the unknown word: ['</u>', 'i', '</u>', 'i', '</u>', 'w', '</u>']另外,tokenize_word函數中關于flag的判斷是我自己加的。
WordPiece
發表論文:Japanese and korean voice search.
 參考:NLP三大Subword模型詳解:BPE、WordPiece、ULM
 信息論(1)——熵、互信息、相對熵
 wordpiece和BPE的差異在于合并時對token對的選擇:BPE是選擇出現次數最大的,wordpiece衡量的是token對和單獨的兩個token之間的概率差,選擇概率差最大的進行合并。
考慮token a和b,以及合并之后的token ab,概率差的公式如下:
 p(a,b)/(p(a)?p(b))p(a,b) / (p(a) * p(b))p(a,b)/(p(a)?p(b))
 這可以近似理解為合并前后,整個語料的互信息。即,當前選擇合并的token對能夠讓語料的熵最小化->確定性最大化->信息量最小化->在計算機中存儲所需要的編碼長度最短化
Unigram
發表論文: Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates
用得上的時候再補充…
總結
以上是生活随笔為你收集整理的[NLP]——BPE、WordPiece、Unigram and SentencePiece的全部內容,希望文章能夠幫你解決所遇到的問題。
                            
                        - 上一篇: 2022面试必刷461道大厂架构面试真题
 - 下一篇: idea插件开发--组件--编程久坐提醒