LLM實(shí)踐系列-詳談Tokenizer訓(xùn)練細(xì)節(jié)
經(jīng)過(guò)了數(shù)據(jù)收集、篩選、去重,馬上就可以開(kāi)始訓(xùn)練實(shí)驗(yàn)了。但是在實(shí)驗(yàn)之前,我們還需要先獲取一個(gè)語(yǔ)言模型的基石:分詞器(Tokenizer)。Tokenizer 的作用是對(duì)一條文本數(shù)據(jù)進(jìn)行切分、詞表映射,得到這條文本的token序列。
用開(kāi)源 Tokenizer 還是自己訓(xùn)練
Tokenizer可以自己訓(xùn)練,也可以從目前開(kāi)源的模型中扒一個(gè)來(lái)用,用開(kāi)源Tokenizer有幾個(gè)點(diǎn)需要著重關(guān)注:
- 壓縮率:壓縮率決定了文本向量化后的長(zhǎng)度,壓縮率越高,向量后數(shù)據(jù)越短,訓(xùn)練和推理效率越高,但是對(duì)訓(xùn)練數(shù)據(jù)的數(shù)量要求也越大,主流的tokenizer對(duì)漢字的壓縮率都在1.5-1.6之間,也就是1.5-1.6個(gè)漢字劃分為一個(gè)token。
- token覆蓋率:token覆蓋率不用糾結(jié)細(xì)節(jié),只需要關(guān)注是否有你的目標(biāo)語(yǔ)種的token,比如llama的tokenizer中文就很少,相應(yīng)地中文上壓縮率就比較低,token向字節(jié)流的退化率比較高,也一定程度的反應(yīng)了中文訓(xùn)練數(shù)據(jù)不多。
- 預(yù)留token數(shù)量:預(yù)留token也叫特殊token,一般寫(xiě)作reserved_token、unused_token,paded_token,都是一個(gè)意思。這些token是指不會(huì)出現(xiàn)在自然語(yǔ)料中,僅保留為后續(xù)post train階段的一些特殊用途使用。比如任務(wù)隔離、角色隔離、function call的特殊指令、agent特殊指令等等。預(yù)留token最好足夠,100-1000為佳。如果下載的tokenizer預(yù)留token不夠,可以手動(dòng)添加。
- 詞表大小:目前開(kāi)源的tokenizer詞表大小一般在8萬(wàn)或15萬(wàn)左右。詞表越大,壓縮率一般也越高,同時(shí)模型的embedding層和logits層也會(huì)更大,對(duì)顯存資源敏感的需要注意一下。
有開(kāi)源tokenizer,訓(xùn)練自己的tokenizer意義何在?用自己的數(shù)據(jù)訓(xùn)練的 tokenizer,在同詞表大小的情況下,會(huì)比開(kāi)源tokenizer有更高的壓縮率(也不會(huì)高太多),可以一定程度降低訓(xùn)練和推理成本。另外更主觀一點(diǎn)的原因是,訓(xùn)tokenizer是個(gè)很基礎(chǔ)的工作,訓(xùn)不訓(xùn)一定程度上反映了團(tuán)隊(duì)的技術(shù)棧是否全面。還有一點(diǎn)需要注意的是,不同壓縮率的tokenizer訓(xùn)練的模型loss可能有差別,但是性能不會(huì)有太大差別。
經(jīng)典的分詞器有WordPiece 、subword-nmt、Unigram等等,但是這些并不是本文的重點(diǎn),本文主要說(shuō)目前最流行的兩種tokenizer:BPE和BBPE。
BPE(Byte Pair Encoding)
BPE是目前大模型主流的兩種分詞法之一,訓(xùn)練過(guò)程總結(jié)成一句話就是:迭代合并當(dāng)前最高頻的token對(duì),直到到達(dá)預(yù)設(shè)詞表大小上限。舉個(gè)具體的例子來(lái)說(shuō),假設(shè)我要訓(xùn)練一個(gè)詞表大小是12的tokenizer,訓(xùn)練語(yǔ)料就是下面這句話:
“海水潮潮潮潮潮潮落,浮云長(zhǎng)長(zhǎng)長(zhǎng)長(zhǎng)長(zhǎng)長(zhǎng)長(zhǎng)消”
1、 這句話首先會(huì)按字拆分成:海 水 潮 潮 潮 潮 潮 潮 落 , 浮 云 長(zhǎng) 長(zhǎng) 長(zhǎng) 長(zhǎng) 長(zhǎng) 長(zhǎng) 長(zhǎng) 消。
我沒(méi)數(shù)錯(cuò)的話算上逗號(hào)應(yīng)該是總共有9個(gè)不重復(fù)的字。這些字會(huì)被當(dāng)作初始token,加入詞表。現(xiàn)在我們離目標(biāo)詞表大小還差12-9=3個(gè)token,下面開(kāi)始迭代合并。
2、 統(tǒng)計(jì)當(dāng)前已經(jīng)加入詞表的所有token兩兩組合在訓(xùn)練語(yǔ)料中出現(xiàn)的次數(shù)。
這里有兩點(diǎn)注意,首先是“當(dāng)前”,也就是說(shuō)每次迭代加入新token后,下一次統(tǒng)計(jì)兩兩組合要算上這個(gè)新token。其次是“所有token兩兩組合”,也就是既要統(tǒng)計(jì)A token與B token的組合,也要統(tǒng)計(jì)A token與自身的組合。比如上面這個(gè)句子,我們要統(tǒng)計(jì)“海水”、“水潮”、“潮潮”、”潮落“....這些所有兩兩組合出現(xiàn)的次數(shù)。
3、 取兩兩組合中出現(xiàn)次數(shù)最高的那一個(gè),作為新token加入詞表,同時(shí)記錄下這個(gè)token的合成路徑。
比如上面“潮潮”和“長(zhǎng)長(zhǎng)”是出現(xiàn)次數(shù)最高的組合,都出現(xiàn)了5次,那么我們?nèi)「绯霈F(xiàn)的“潮潮”作為本次要加入詞表的新token,同時(shí)記錄下”潮“+”潮“=”潮潮“。現(xiàn)在詞表大小為10。
4、 如果詞表沒(méi)有達(dá)到設(shè)定的上限12,那么就迭代執(zhí)行2-3步。
再一次統(tǒng)計(jì)兩兩組合出現(xiàn)次數(shù),這一次最多的就是剛才并列第一的“長(zhǎng)長(zhǎng)”。當(dāng)然也不要忘記上一步剛加入的“潮潮”這個(gè)token,他可以和前面的token組成“水潮潮”,也可以和后面的token組成“潮潮潮”,不過(guò)次數(shù)都不如“長(zhǎng)長(zhǎng)”。所以這一次加入詞表的是“長(zhǎng)長(zhǎng)”。
再迭代一次,再統(tǒng)計(jì)組合,此時(shí)次數(shù)最多的是“潮潮”+“潮” 組成的“潮潮潮”,以及“長(zhǎng)長(zhǎng)”+“長(zhǎng)”組成的“長(zhǎng)長(zhǎng)長(zhǎng)”,分別出現(xiàn)4次。按照之前的原則,取次數(shù)最多且更靠前的“潮潮潮”加入詞表,此時(shí)詞表大小為12,訓(xùn)練停止,我們已經(jīng)得到了大小為12,在“海水潮潮潮潮潮潮落,浮云長(zhǎng)長(zhǎng)長(zhǎng)長(zhǎng)長(zhǎng)長(zhǎng)長(zhǎng)消”上訓(xùn)練的分詞器。
BPE的訓(xùn)練過(guò)程還是很簡(jiǎn)單很好理解的,但是還是有一些需要注意的地方。上面的解釋中有兩個(gè)我刻意嚴(yán)謹(jǐn)表達(dá)的點(diǎn),一個(gè)是「取次數(shù)最多且更靠前的“潮潮潮”加入詞表」,另一個(gè)是「“潮潮”+“潮” 組成的“潮潮潮”,以及“長(zhǎng)長(zhǎng)”+“長(zhǎng)”組成的“長(zhǎng)長(zhǎng)長(zhǎng)”」。前者想說(shuō)明token加入詞表的順序是有先后的,是有優(yōu)先級(jí)的,后者說(shuō)明token的合成路徑方式需要嚴(yán)格遵循,改變?cè)~表可能導(dǎo)致錯(cuò)誤的合成路徑。
特意強(qiáng)調(diào)這個(gè)是因?yàn)槲铱吹揭恍└脑~表的開(kāi)源工作其實(shí)是有問(wèn)題的。舉個(gè)我看到的實(shí)際的例子,有一個(gè)地名”烏魯木齊“,假設(shè)詞表中包含”烏魯“和”魯木“兩個(gè)token。首先說(shuō)可不可能出現(xiàn)這倆token?完全可能,如果我的訓(xùn)練語(yǔ)料是下面這樣的就會(huì)出現(xiàn)這兩個(gè)token:
烏魯
烏魯
魯木
魯木
齊
這個(gè)語(yǔ)料訓(xùn)出來(lái)的tokenizer詞表大概率是這樣的:「烏」「魯」「木」「齊」「烏魯」「魯木」
如果詞表里烏魯這個(gè)token在前,分詞結(jié)果就是 「烏魯」「 木」「 齊」,如果是魯木這個(gè)token在前,分詞結(jié)果就是「烏」「魯木」「齊」。分詞結(jié)果是不一樣的。如果拿到一個(gè)訓(xùn)練過(guò)的模型,改一改詞表順序,肯能會(huì)導(dǎo)致分詞結(jié)果的不一致,模型可能完全沒(méi)有見(jiàn)過(guò)這樣的token,導(dǎo)致一些無(wú)法理解的怪異生成結(jié)果。
再說(shuō)合成路徑,如果我的詞表是 「烏」「魯」「木」「齊」「烏魯」「木齊」,后來(lái)擴(kuò)增了詞表,增加了「烏魯」+「木」=「烏魯木」,和「烏魯木」+「齊」=「烏魯木齊」兩個(gè)token,「烏魯木齊」這個(gè)token是合成不出來(lái)的,因?yàn)椤改君R」在「烏魯木」之前,所以優(yōu)先合成「木齊」,而不是「烏魯木」,那么沒(méi)有「烏魯木」,自然無(wú)法合成「烏魯木齊」。如果要進(jìn)行詞表刪減、擴(kuò)增,或者兩個(gè)tokenizer進(jìn)行合并,尤其要注意這個(gè)問(wèn)題。
BBPE(Byte-Level Byte Pair Encoding)
BBPE和BPE大體上是一樣的,區(qū)別在于BPE把文本看作字符集合,第一步是按照字符切分獲得初始token,BBPE把文本看作是二進(jìn)制編碼,按照8bit切分獲得原始token。比如還是上面那句話,會(huì)先轉(zhuǎn)成utf8編碼:
“海水潮潮潮潮潮潮落,浮云長(zhǎng)長(zhǎng)長(zhǎng)長(zhǎng)長(zhǎng)長(zhǎng)長(zhǎng)消” =>
"\xe6\xb5\xb7\xe6\xb0\xb4\xe6\xbd\xae\xe6\xbd\xae\xe6\xbd\xae\xe6\xbd\xae\xe6\xbd\xae\xe6\xbd\xae\xe8\x90\xbd\x2c..."
然后取每一個(gè)2位16進(jìn)制數(shù)作為初始token,也就是「\xe6」「\xb5」「\xb7」...這些。剩下的統(tǒng)計(jì)兩兩組合、合成路徑都和BPE是一樣的,不過(guò)都是在二進(jìn)制層面去合并。我們知道utf8是變長(zhǎng)編碼,ascii字符在utf8中的編碼長(zhǎng)度是1,也就是剛好一個(gè)2位16進(jìn)制數(shù)。比如我上面句子里的逗號(hào)對(duì)應(yīng)的utf8編碼是“\x2c”。所以ascii字符一定會(huì)作為一個(gè)基礎(chǔ)字符加入詞表,而且也不會(huì)被拆分,所以英文單詞、數(shù)字這種ascii字符組成的詞,一定是整數(shù)個(gè)token表示的。但是漢字的編碼長(zhǎng)度大部分是3,比如“海”的編碼是“\xe6\xb5\xb7”,這就導(dǎo)致漢字在bbpe的詞表中并不一定是1個(gè)、2個(gè)字這種整數(shù)個(gè)token組成。可能是3/2個(gè)token表示一個(gè)漢字。
BBPE與BPE的對(duì)比
從流行度來(lái)說(shuō),BPE是去年、前年大家普遍使用的方法,而B(niǎo)BPE是去年底到今年的模型主要使用的方法。GPT2使用的tokenizer也是BBPE。
從編碼的角度,有一些文章說(shuō)BBPE對(duì)比BPE的優(yōu)勢(shì)是不存在OOV問(wèn)題,字符只要能轉(zhuǎn)utf8,就一定能被BBPE表示,但是實(shí)際執(zhí)行起來(lái)并不是,因?yàn)榇蟛糠諦PE的庫(kù)也支持bytes退化,遇到超出詞表范圍的字符,也會(huì)退化到二進(jìn)制表示。這么看下來(lái)BBPE剩下的優(yōu)點(diǎn)就是多語(yǔ)種下、token切分更均勻。畢竟一個(gè)中文能占3/2個(gè)token了。
從實(shí)現(xiàn)的角度,BPE的tokenizer用sentencepice庫(kù)的居多,BBPE用huggingface的tokenizers庫(kù)的居多,但是sentencepice庫(kù)產(chǎn)出的tokenizer.model本質(zhì)是一個(gè)protobuf文件,可以用protobuf庫(kù)讀出這個(gè)tokenizer原始的訓(xùn)練參數(shù),甚至帶著訓(xùn)練語(yǔ)料的磁盤(pán)路徑,不太安全。
訓(xùn)練參數(shù)
除了最基本的詞表大小外,實(shí)際訓(xùn)練的tokenizer還有一些可配置關(guān)鍵參數(shù)。我比較喜歡讀google的文檔,就拿sentencepice的訓(xùn)練參數(shù)來(lái)介紹了,兩個(gè)庫(kù)的可配置參數(shù)其實(shí)差不多,可以類比。
--vocab_size
tokenizer預(yù)設(shè)的詞表大小。最終模型訓(xùn)練的時(shí)候,我們一般會(huì)確保embedding層的shape可以被128整除,這個(gè)一方面是為了量化考慮,一方面是為了序列并行考慮。所以可以在這一步直接設(shè)置一個(gè)能被128整除的詞表大小,也可以這里不設(shè)置,等tokenizer訓(xùn)練完了加一些特殊token,補(bǔ)到128的倍數(shù)。另外詞表大小也決定了壓縮率。
--character_coverage
字符覆蓋率。這個(gè)表示在最一開(kāi)始,初始單字token要覆蓋訓(xùn)練語(yǔ)料中全部token的百分之多少。如果是1,表示所有token只要出現(xiàn)就加入初始詞表,這會(huì)導(dǎo)致詞表的單字token過(guò)多。一般可以設(shè)置0.9998、0.9999,表示初始單字token要覆蓋訓(xùn)練集字符的99.99%
--max_sentencepiece_length
單一token最多多少個(gè)字符組成,一般設(shè)為2、4、6,8或以上就比較大了,不太推薦,可能會(huì)出現(xiàn)低頻超長(zhǎng)token。
--split_digits
是否做數(shù)字的一致性切分,說(shuō)白了就是數(shù)字和其他token是否可以組合成新token,還是數(shù)字必須一個(gè)字符一個(gè)token不做合并。早期一些工作認(rèn)為開(kāi)了對(duì)數(shù)學(xué)任務(wù)有好處,但是從我的實(shí)驗(yàn)上來(lái)看,是可以打開(kāi),但不是必須。數(shù)字在自然界是均勻分布的,也就是說(shuō)1、2、3還是111、222、132、863數(shù)量其實(shí)是差不多的,所以就算不開(kāi)一致性切分,這些token也都應(yīng)該在詞表里。只要模型訓(xùn)練充分,模型是能分辨111和1 1 1是一個(gè)東西的。我一直認(rèn)為不應(yīng)該對(duì)語(yǔ)言模型的泛化抱有太樂(lè)觀的態(tài)度,它就只能說(shuō)出見(jiàn)過(guò)的話。所以我不指望在欠訓(xùn)練的模型上,依賴一致性切分提高數(shù)學(xué)能力。當(dāng)然這是理想情況,如果數(shù)據(jù)集準(zhǔn)備的不充分,不能保證所有這些數(shù)字都出現(xiàn)在詞表里,可以手動(dòng)把0-9999添加到詞表里,這樣既保持一致性,又能提高壓縮率。說(shuō)個(gè)題外話,前段時(shí)間知乎上流行討論為什么9.11>9.8,我認(rèn)為就是token欠訓(xùn)練,又剛好沒(méi)有泛化過(guò)來(lái)。為什么沒(méi)有泛化過(guò)來(lái)呢?因?yàn)檎娴挠?.11大于9.8的時(shí)候。python3.11就大于python3.8
--allow_whitespace_only_pieces
允許多個(gè)空格作為一個(gè)token,一般是允許,主要是為了排版,比如python的代碼排版。當(dāng)然也可以不開(kāi),手動(dòng)把1-20個(gè)空格組成的token添加到詞表里。
-user_defined_symbols
這個(gè)主要就是為了配置之前說(shuō)的特殊token,可以預(yù)留幾百比如<reserved_0> <reserved_1> ...,如果要手動(dòng)添加數(shù)字的一致切分和連續(xù)空格token,也可以在這里加
--byte_fallback
之前說(shuō)的二進(jìn)制退化,讓BPE當(dāng)半個(gè)BBPE用
--remove_extra_whitespaces
是否刪除多余空格,這個(gè)一般改為False,不要讓tokenizer隨便動(dòng)我們的空格。
--unk_id 、--bos_id、--eos_id、--pad_id 、 --unk_piece、--bos_piece、--eos_piece、--pad_piece
指定控制字符和ID,這里面現(xiàn)在我們一般只用pad和eos。在訓(xùn)練的時(shí)候文檔或者一個(gè)turn的末尾增加一個(gè)eos token。需要做padding補(bǔ)齊的時(shí)候拼pad token,也可以直接用eos token當(dāng)補(bǔ)齊token。不過(guò)建議四個(gè)都設(shè)置上,也算是致敬一下之前的NLPer
番外篇1:tokenizer與loss
不同tokenizer的壓縮率,每個(gè)token的信息量是不同的,這就導(dǎo)致不同tokenizer在同一份數(shù)據(jù)下訓(xùn)練出來(lái)的模型loss不一樣。假設(shè)不考慮訓(xùn)練tokenizer的語(yǔ)料質(zhì)量差異,那么tokenizer的壓縮率越高,同一個(gè)文本分詞后產(chǎn)生的token越少,整句話的平均loss就越高。相反壓縮率越低,一句話的loss通常也越低。但是實(shí)際推理起來(lái),兩種tokenizer訓(xùn)出來(lái)的模型效果差異不大,所以更多的還是從效率和成本的角度考慮壓縮率的取舍吧。
再引申一下,從這一點(diǎn)上不禁讓我們升起一個(gè)疑惑,loss能代表模型性能嗎?答案是不行。其實(shí)同loss不同tokenizer、同loss不同參數(shù)量的模型性能都不一樣。這個(gè)具體可以等寫(xiě)到scaling law擬合、模型性能預(yù)測(cè)或者continue pretraining的時(shí)候再討論。
番外篇2:看一看真實(shí)的tokenizer
再拿qwen2的tokenizer舉例,qwen2的tokenizer時(shí)BBPE,重要文件有這么幾個(gè):
圖片
merges.txt就是保存合成路徑的文件,里面看上去會(huì)有一些像亂碼的東西:
圖片
這些其實(shí)是轉(zhuǎn)義后的控制字符。前面也說(shuō)了BBPE的初始token是2位16進(jìn)制數(shù)。如果直接存這些二進(jìn)制字符,可能被翻譯成ascii碼中的控制字符,比如換行制表符之類的,所以這里坐了下轉(zhuǎn)義。
vocab.json是每次token的映射id,tokenizer_config.json里面可以配置一下控制字符。tokenizer訓(xùn)練完之后如果想加特殊字符,也可以在這里配置。
番外篇3:詞表增減問(wèn)題
詞表的修改最好發(fā)生在模型訓(xùn)練之前,包括tokenizer合并、添加特殊token、自定義token等等,這其中還尤其要注意增加詞表。語(yǔ)言模型訓(xùn)練的時(shí)候,計(jì)算logits時(shí)是hidden_states和所有token的logits_weight的乘積,softmax也是所有token的歸一,刪除詞表相當(dāng)于減小歸一化的分母,會(huì)導(dǎo)致最后sample時(shí)的概率發(fā)生變化。添加token則跟要小心,如果添加的token未經(jīng)訓(xùn)練,可能導(dǎo)致歸一化后亂掉。如果添加的token初始化的不好,比如正常token是1e-2,新增加的token是1e-23量級(jí),rms norm會(huì)回傳給embedding層一個(gè)大概在1e21量級(jí)的梯度。這個(gè)時(shí)候如果你開(kāi)了梯度裁切,在求梯度的模長(zhǎng)的時(shí)候1e21求平方直接變成inf,再歸一化其他token,所有token梯度都會(huì)變成0,這樣你不太看得出來(lái)報(bào)錯(cuò),但是embedding實(shí)際一點(diǎn)沒(méi)訓(xùn)。
再有就是前文強(qiáng)調(diào)過(guò)的,注意處理合成路徑和token順序的沖突問(wèn)題。
