1. 引言
分詞器是每個大語言模型必不可少的組件,但每個大語言模型的分詞器幾乎都不相同。如果要訓練自己的分詞器,可以使用huggingface的tokenizers框架,tokenizers包含以下主要組件:
- Tokenizer: 分詞器的核心組件,定義了分詞的整個流程,包括標準化、預分詞、模型分詞、后處理等
- Normalizers:可選,負責將文本標準化,包括unicode歸一化、大寫轉小寫、去重音等操作
- Pre-tokenizers:負責將文本分割成更小的片段(如單詞等),為模型分詞做準備。常見的預分詞器有按空格分詞(Whitespace)、正則表達式分詞(Regex)等
- Models:是實際的分詞算法,負責將文本片段轉換為子詞,常見的有BPE、WordPiece、Unigram等。
- Post-Processors:負責對分詞結果進行后處理,如添加特殊標記(CLS、SEP)。
- Decoders:負責將分詞結果轉換回原始文本,常見的解碼器有 ByteLevel、WordPiece 等。
- Trainers:用于訓練分詞模型,不同的模型對應不同的訓練器,如 BpeTrainer、WordPieceTrainer、UnigramTrainer 等。
在開始之前,先導入對應的包。
import json
import re
import os
from tokenizers import (decoders,models,normalizers,pre_tokenizers,processors,trainers,Tokenizer,
)
2. 加載語料庫
我們準備好的語料庫是一個jsonl文件,大概有736MB,每一行是一條json格式的文本數據,既有中文,也有英文。
!ls -n /data2/minigpt/dataset/tokenize/tokenizer_train.jsonl
-rw-rw-r-- 1 736729803 Nov 4 22:04 /data2/minigpt/dataset/tokenize/tokenizer_train.jsonl
定義一個函數用于從JSONL文件中讀取文本數據,考慮到語料庫會比較大,所以采用yield
生成器來延遲到訪問時再加載數據。
def read_texts_from_jsonl(file_path):with open(file_path, 'r', encoding='utf-8') as f:for i, line in enumerate(f):data = json.loads(line)yield data['text']
data_path = '/data2/minigpt/dataset/tokenize/tokenizer_train.jsonl'
texts = read_texts_from_jsonl(data_path)
type(texts)
generator
可以看到,函數read_texts_from_jsonl
返回的并不是真正的數據,而是一個生成器generator
。可以通過next
函數像訪問iterator
一樣訪問迭代數據。
next(texts)
'好的。現在請你將這個文本中的所有的逗號都替換成空格。 好的,請稍等一下,現在我會將文本中的所有逗號替換為空格。處理后文本為:"這是一個句子 目的是看看是否可以正確地從這個句子中刪除關鍵詞。"。處理結果如何?'
3. 訓練過程
3.1 模型選擇
使用BPE模型來初始化Tokenizer實例。
tokenizer = Tokenizer(models.BPE())
BPE是一種基于子詞的分詞方法,例如:
- cats -> cat + s
- helpful -> help + ful
- congratulation -> con + gr + at + ulation
這種基于子詞的分詞方法,相比基于完整單詞和基于單個字符有以下好處:
- 子詞相比于單詞(可以認為多個子詞的組合)數量要可控,這能避免詞表過大,并且能避免生僻詞帶來的未知令牌問題。
- 子詞相比于字符語義性更強,像單個字符
f
是沒有語義的,但子詞ful
可以表達滿的
,比較像英語里的詞根詞綴。
3.2 預分詞器選擇
為tokenizer設置預分詞器,預分詞器有以下幾類:
- Whitespace:按空格分隔,粒度為單詞,適用于空格分隔的語言,例如英語。
- Regex:按自定義正則表達式分隔,適用于需要自定義復雜分詞規則的場景。
- ByteLevel:按字節分割,適用于特殊字符、非英語場景(例如中文)。
由于我們主要面向中文,所以這里采用ByteLevel的pre_tokenizer。
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
tokenizer.pre_tokenizer
<tokenizers.pre_tokenizers.ByteLevel at 0x7f41641266f0>
預分詞器的方法列表
dir(tokenizer.pre_tokenizer)
[……'add_prefix_space','alphabet','custom','pre_tokenize','pre_tokenize_str','use_regex']
那pre_tokenizer具體對文本作了什么處理呢?可以通過下面兩個例子來觀察下。
- 測試英文文本處理。
tokenizer.pre_tokenizer.pre_tokenize_str("Pre-tokenize a :class:`~tokenizers.PyPreTokenizedString` in-place")
[('Pre', (0, 3)),('-', (3, 4)),('tokenize', (4, 12)),('?a', (12, 14)),('?:', (14, 16)),('class', (16, 21)),(':`~', (21, 24)),('tokenizers', (24, 34)),('.', (34, 35)),('PyPreTokenizedString', (35, 55)),('`', (55, 56)),('?in', (56, 59)),('-', (59, 60)),('place', (60, 65))]
可以看到,pre_tokenizer將文本按照空格和特殊字符作了初步分詞,空格處理成了特殊字符?
,并記錄了每個詞的起始和結束位置。
- 測試中文文本處理。
zh_sentence = "在查處虛開增值稅專用發票案件中,常常涉及進項留抵稅額和稅款損失的認定和處理。"
tokenizer.pre_tokenizer.pre_tokenize_str(zh_sentence)
[('??¨??¥?¤?è??????¢?????¨??????¨????¥¨??ī??????', (0, 15)),('???', (15, 16)),('????????ī???è??é?1?????μ?¨?é¢?????¨????????¤±???è?¤???????¤????', (16, 37)),('???', (37, 38))]
中文基本也是按照特殊符號,
和。
進行了分詞,但分詞的結果是一堆不認識的字符,這些字符是如何產生的呢?
3.3 預分詞原理探究
預分詞常常使用類似下面一樣的正則表達式先對文本進行分隔。
import regex as rePRETOKENIZE_REGEX = r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}\p{P}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+"""
pat = re.compile(PRETOKENIZE_REGEX)
tokens = re.findall(pat, zh_sentence)
tokens
['在查處虛開增值稅專用發票案件中', ',', '常常涉及進項留抵稅額和稅款損失的認定和處理', '。']
其中,各部分正則表達式的作用如下:
- (?i:'s|'t|'re|'ve|'m|'ll|'d): 匹配常見的英文縮略形式,例如:'s(is 或 has),'t(not),'re(are),'ve(have),'m(am),'ll(will),'d(would 或 had)。
- [^\r\n\p{L}\p{N}\p{P}]?\p{L}+:匹配一個或多個 Unicode 字母
\p{L}
,這里的unicode字母包括英文、中文、拉丁等所有語言中的字母,允許前面有一個非換行符\r\n
、非字母\p{L}
、非數字\p{N}
和非標點\p{P}
的字符,相當于是匹配空格、制表符等空白字符。 - \p{N}:匹配任何 Unicode 數字字符。
- ?[^\s\p{L}\p{N}]+[\r\n]*:匹配非空白、非字母、非數字的字符,允許前面有一個空格,后面跟隨換行符,相當于是匹配標點符號后面跟換行符。
- \p{L}:匹配任何 Unicode 字母字符,包括拉丁字母、希臘字母、漢字等所有語言中的字母。
- \p{N}:匹配任何 Unicode 數字字符,涵蓋阿拉伯數字、羅馬數字等所有形式的數字字符。
- \p{P}:匹配任何 Unicode 標點字符,涵蓋句號、逗號、引號、括號等所有形式、所有語言中的標點符號。
對于英文以外的其它語言(例如中文),需要進行utf-8編碼,將字符編碼為字節。
utf-8編碼的目的是解決英文、中文、日文、俄文等多語言的問題,因為世界上所有語言的字符都可以用一個或多個utf-8字節的組合來表示。
tokens_utf8 = [token.encode("utf-8") for token in tokens]
tokens_utf8
[b'\xe5\x9c\xa8\xe6\x9f\xa5\xe5\xa4\x84\xe8\x99\x9a\xe5\xbc\x80\xe5\xa2\x9e\xe5\x80\xbc\xe7\xa8\x8e\xe4\xb8\x93\xe7\x94\xa8\xe5\x8f\x91\xe7\xa5\xa8\xe6\xa1\x88\xe4\xbb\xb6\xe4\xb8\xad',b'\xef\xbc\x8c',b'\xe5\xb8\xb8\xe5\xb8\xb8\xe6\xb6\x89\xe5\x8f\x8a\xe8\xbf\x9b\xe9\xa1\xb9\xe7\x95\x99\xe6\x8a\xb5\xe7\xa8\x8e\xe9\xa2\x9d\xe5\x92\x8c\xe7\xa8\x8e\xe6\xac\xbe\xe6\x8d\x9f\xe5\xa4\xb1\xe7\x9a\x84\xe8\xae\xa4\xe5\xae\x9a\xe5\x92\x8c\xe5\xa4\x84\xe7\x90\x86',b'\xe3\x80\x82']
但有個問題是:Ascii碼中是會包含回車、制表、換行等控制字符的,同樣utf-8編碼中也會有。而我們最終構造的詞表必須是可顯示的文本,所以還要做一個工作是把控制字符都轉換為可顯示字符,為此需要制作一個unicode字節編碼表,用于將單字節(256以內)都編碼為可顯示字符。
0-255范圍內的可顯示字符分為三段:
- 從 !(ASCII 33)到 ~(ASCII 126)。
- 從 ?(Unicode 161)到 ?(Unicode 172)。
- 從 ?(Unicode 174)到 ?(Unicode 255)。
這三段以外的ASCII碼均無法正常顯示,需要用可顯示字符來填充替代。
def bytes_to_unicode():# 收集0-255范圍內的可顯示字符對應的數字值,ord函數用于將字符編碼為數字bs = (list(range(ord("!"), ord("~") + 1)) + list(range(ord("?"), ord("?") + 1)) + list(range(ord("?"), ord("?") + 1)))cs = bs[:]n = 0# 補充0-255范圍內不可顯示字符對應的數字,并轉換為256以上可顯示字符對應的數字值for b in range(2**8):if b not in bs:bs.append(b)cs.append(2**8 + n)n += 1# chr函數用于將數字轉換回unicode字符,并創建一個字節值到字符值的映射表。cs = [chr(n) for n in cs]return dict(zip(bs, cs))byte_encoder = bytes_to_unicode()
json.dumps(byte_encoder, ensure_ascii=False)
'{"33": "!", "34": "\\"", "35": "#", "36": "$", "37": "%", "38": "&", "39": "\'", "40": "(", "41": ")", "42": "*", "43": "+", "44": ",", "45": "-", "46": ".", "47": "/", "48": "0", "49": "1", "50": "2", "51": "3", "52": "4", "53": "5", "54": "6", "55": "7", "56": "8", "57": "9", "58": ":", "59": ";", "60": "<", "61": "=", "62": ">", "63": "?", "64": "@", "65": "A", "66": "B", "67": "C", "68": "D", "69": "E", "70": "F", "71": "G", "72": "H", "73": "I", "74": "J", "75": "K", "76": "L", "77": "M", "78": "N", "79": "O", "80": "P", "81": "Q", "82": "R", "83": "S", "84": "T", "85": "U", "86": "V", "87": "W", "88": "X", "89": "Y", "90": "Z", "91": "[", "92": "\\\\", "93": "]", "94": "^", "95": "_", "96": "`", "97": "a", "98": "b", "99": "c", "100": "d", "101": "e", "102": "f", "103": "g", "104": "h", "105": "i", "106": "j", "107": "k", "108": "l", "109": "m", "110": "n", "111": "o", "112": "p", "113": "q", "114": "r", "115": "s", "116": "t", "117": "u", "118": "v", "119": "w", "120": "x", "121": "y", "122": "z", "123": "{", "124": "|", "125": "}", "126": "~", "161": "?", "162": "¢", "163": "£", "164": "¤", "165": "¥", "166": "|", "167": "§", "168": "¨", "169": "?", "170": "a", "171": "?", "172": "?", "174": "?", "175": "ˉ", "176": "°", "177": "±", "178": "2", "179": "3", "180": "′", "181": "μ", "182": "?", "183": "·", "184": "?", "185": "1", "186": "o", "187": "?", "188": "?", "189": "?", "190": "?", "191": "?", "192": "à", "193": "á", "194": "?", "195": "?", "196": "?", "197": "?", "198": "?", "199": "?", "200": "è", "201": "é", "202": "ê", "203": "?", "204": "ì", "205": "í", "206": "?", "207": "?", "208": "D", "209": "?", "210": "ò", "211": "ó", "212": "?", "213": "?", "214": "?", "215": "×", "216": "?", "217": "ù", "218": "ú", "219": "?", "220": "ü", "221": "Y", "222": "T", "223": "?", "224": "à", "225": "á", "226": "a", "227": "?", "228": "?", "229": "?", "230": "?", "231": "?", "232": "è", "233": "é", "234": "ê", "235": "?", "236": "ì", "237": "í", "238": "?", "239": "?", "240": "e", "241": "?", "242": "ò", "243": "ó", "244": "?", "245": "?", "246": "?", "247": "÷", "248": "?", "249": "ù", "250": "ú", "251": "?", "252": "ü", "253": "y", "254": "t", "255": "?", "0": "ā", "1": "ā", "2": "?", "3": "?", "4": "?", "5": "?", "6": "?", "7": "?", "8": "?", "9": "?", "10": "?", "11": "?", "12": "?", "13": "?", "14": "?", "15": "?", "16": "?", "17": "?", "18": "ē", "19": "ē", "20": "?", "21": "?", "22": "?", "23": "?", "24": "?", "25": "?", "26": "ě", "27": "ě", "28": "?", "29": "?", "30": "?", "31": "?", "32": "?", "127": "?", "128": "?", "129": "?", "130": "?", "131": "?", "132": "?", "133": "?", "134": "?", "135": "?", "136": "ī", "137": "ī", "138": "?", "139": "?", "140": "?", "141": "?", "142": "?", "143": "?", "144": "?", "145": "?", "146": "?", "147": "?", "148": "?", "149": "?", "150": "?", "151": "?", "152": "?", "153": "?", "154": "?", "155": "?", "156": "?", "157": "?", "158": "?", "159": "?", "160": "?", "173": "?"}'
這樣,每個字節值都從 Unicode 表的開頭獲得一個分配給它的
可見
字符。這一點非常重要,因為每個utf-8字符都是由一到多個字節組成的,將這個長度為256的編碼表中的字節進行組合,理論上就能對世界上所有語言中的字符進行編碼,并且還不會出現未知
標記。
使用這個unicode字節編碼表將前面utf-8編碼后的文本序列進行ByteLevel級的編碼。
tokens_unicode = ["".join(byte_encoder[b] for b in token) for token in tokens_utf8]
tokens_unicode
['??¨??¥?¤?è??????¢?????¨??????¨????¥¨??ī??????','???','????????ī???è??é?1?????μ?¨?é¢?????¨????????¤±???è?¤???????¤????','???']
可以看到,結果與使用pre_tokenizer預分詞的結果完全相同。
3.4 構建訓練器
BPE訓練器中需要指定幾個參數:
- vocab_size:訓練后詞表中的詞條數量,BPE是一個從短詞到長詞的組合過程,達到詞表大小后就會停止訓練。
- special_tokens:特殊token,和語言模型的特殊token相同,例如開始、結束、填充標記。
- initial_alphabet:初始字符表,使用上面長度為256的unicode字節編碼表作為初始字符表。
通過pre_tokenizers.ByteLevel.alphabet()
可以獲得初始字符編碼表。
json.dumps(pre_tokenizers.ByteLevel.alphabet(), ensure_ascii=False)
'["?", "\\\\", "?", "v", "?", "?", "?", "°", "?", "?", "?", "?", "]", "Q", "?", "G", "?", "?", "é", "H", "9", ")", "×", "í", "ó", "o", "£", "~", "ā", "s", "?", "2", "Y", "í", "a", "·", "?", "y", "?", "2", "4", "ù", "?", "?", "?", "ī", "z", "K", "?", "N", "?", "1", "n", "b", "ó", "?", "?", "V", "?", "6", "?", "?", "O", "?", "j", "h", "?", "?", "¨", "ˉ", "?", "I", "0", "?", "=", "?", "?", "?", "?", "?", "ê", "B", "a", "W", "_", "S", "?", "?", "q", "?", "?", "ò", "?", "?", "L", "?", "U", "#", "?", "?", "?", "′", "?", "M", "&", "D", "¤", "?", "?", "Y", "R", "e", "?", "?", ">", "?", "?", "d", "é", "à", "?", "<", "?", "1", "?", "/", "?", "?", "X", "ê", "u", "?", "m", "w", "ì", "¢", "?", "C", "t", "?", "ì", "ě", "?", "l", "ē", "?", "?", "3", "÷", "{", "$", "y", "?", "?", "?", "?", "?", "?", "?", "a", "T", ";", "?", "ú", "f", "§", "Z", "+", "\'", "?", "A", "?", "?", "?", "?", "c", "%", "ē", "?", "ī", "?", "?", "(", "?", "?", "?", "^", "P", "±", "x", ",", "i", "?", "?", "-", "3", "?", "*", "?", "ú", "?", "?", "?", "5", "ò", "?", "?", "t", "J", "?", ":", "ü", "¥", "`", "è", "è", "?", "?", "}", "!", "r", "T", "g", "\\"", "[", "à", "ā", "?", "7", "?", "?", "F", "?", "á", "á", "D", ".", "@", "E", "?", "?", "e", "?", "ù", "?", "?", "?", "|", "ě", "p", "ü", "8", "|", "k", "o", "μ"]'
定義特殊token,分別為填充、開始、結束標記。
special_tokens = ["<|endoftext|>", "<|im_start|>", "<|im_end|>"]
構建訓練器,詞條數量設置為32000。
trainer = trainers.BpeTrainer(vocab_size=32000,special_tokens=special_tokens, # 確保這三個token被包含show_progress=True,initial_alphabet=pre_tokenizers.ByteLevel.alphabet()
)
使用上面的texts生成器作為語料庫,使用trainer開始訓練
分詞器。
tokenizer.train_from_iterator(texts, trainer=trainer)
注:這個訓練過程的用時長短與文本數據大小有關,我們前面加載的文本數據有700多MB, 大概需要十幾分鐘。
3.5 保存訓練結果
在保存結果之前,需要先設置相匹配的解碼器,否則ASCII以外的字符可能無法正常解碼。
上面編碼階段使用了ByteLevel的預分詞器,相對應的解碼階段也需要使用ByteLevel,表示將token id轉換為token后,還需要進行一次unicode字節級別的解碼,才能正常顯示中文等多語言字符。
tokenizer.decoder = decoders.ByteLevel()
將訓練的分詞器保存到指定目錄。
tokenizer_dir = "/data2/minigpt/models/tokenizer_v3"
os.makedirs(tokenizer_dir, exist_ok=True)
tokenizer.save(os.path.join(tokenizer_dir, "tokenizer.json"))
tokenizer.model.save(tokenizer_dir)
['/data2/minigpt/models/tokenizer_v3/vocab.json','/data2/minigpt/models/tokenizer_v3/merges.txt']
還需要一個分詞器配置文件,包括模型類型、是否使用小寫字母等。
config = {"add_bos_token": False,"add_eos_token": False,"add_prefix_space": True,"added_tokens_decoder": {"0": {"content": "<|endoftext|>","lstrip": False,"normalized": False,"rstrip": False,"single_word": False,"special": True},"1": {"content": "<|im_start|>","lstrip": False,"normalized": False,"rstrip": False,"single_word": False,"special": True},"2": {"content": "<|im_end|>","lstrip": False,"normalized": False,"rstrip": False,"single_word": False,"special": True}},"additional_special_tokens": [],"bos_token": "<|im_start|>","clean_up_tokenization_spaces": False,"eos_token": "<|im_end|>","legacy": True,"model_max_length": 1000000000000000019884624838656,"pad_token": None,"sp_model_kwargs": {},"spaces_between_special_tokens": False,"tokenizer_class": "PreTrainedTokenizerFast","unk_token": "<|endoftext|>","use_default_system_prompt": False,"chat_template": "{% if messages[0]['role'] == 'system' %}{% set system_message = messages[0]['content'] %}{% endif %}{% if system_message is defined %}{{ system_message }}{% endif %}{% for message in messages %}{% set content = message['content'] %}{% if message['role'] == 'user' %}{{ '<|im_start|>user\\n' + content + '<|im_end|>\\n<|im_start|>assistant\\n' }}{% elif message['role'] == 'assistant' %}{{ content + '<|im_end|>' + '\\n' }}{% endif %}{% endfor %}"
}
保存分詞器配置
with open(os.path.join(tokenizer_dir, "tokenizer_config.json"), "w", encoding="utf-8") as config_file:json.dump(config, config_file, ensure_ascii=False, indent=4)print("Tokenizer training completed and saved.")
Tokenizer training completed and saved.
查看磁盤上的詞表文件。
!ls -l /data2/minigpt/models/tokenizer_v3
total 2548
-rw-rw-r-- 1 golfxiao golfxiao 407951 Oct 10 21:45 merges.txt
-rw-rw-r-- 1 golfxiao golfxiao 1686 Oct 10 21:45 tokenizer_config.json
-rw-rw-r-- 1 golfxiao golfxiao 1572840 Oct 10 21:45 tokenizer.json
-rw-rw-r-- 1 golfxiao golfxiao 621912 Oct 10 21:45 vocab.json
- vocab.json:詞匯表文件,包含詞條和對應的索引。
- merges.txt: 合并表文件,定義了子詞的合并規則。
- tokenizer.json: 完整的分詞器文件,它包含了分詞器的所有信息,包括詞匯表、合并規則、特殊標記等。
- tokenizer_config.json: 分詞器配置文件,包括了起始token、結束token的定義,以及提示詞模板。
4. 測試分詞器
from transformers import AutoTokenizer# 加載剛訓練的tokenizer
tokenizer_dir = "/data2/minigpt/models/tokenizer_v3"
tokenizer_trained = AutoTokenizer.from_pretrained(tokenizer_dir)
4.1 英文文本測試
先測試英文文本的分詞。
text_en = "Pre-tokenize a :class:`~tokenizers.PyPreTokenizedString` in-place"
tokenized = tokenizer_trained.tokenize(text_en)
tokenized
['Pre','-','token','ize','?a','?:','class',':','`','~','token','izers','.','Py','Pre','T','oken','ized','String','`','?in','-','place']
tokenize方法只負責分詞,就是將一串文本切分為token列表。我們在給模型輸入時一般需要的是token_id,這時就需要使用encode方法,同時完成token切分和文本到數字的序列化。
token_ids_en = tokenizer_trained.encode(text_en)
token_ids_en
[19714,15,24535,1038,260,6938,9939,28,66,96,24535,11344,16,22966,19714,54,9071,1228,13863,66,295,15,2383]
對上面的token_id進行反序列化,以測試解碼功能。
tokenizer_trained.decode(token_ids_en)
'Pre-tokenize a :class:`~tokenizers.PyPreTokenizedString` in-place'
可以看到,解碼的結果與原始英文串完全相同。
4.2 中文文本測試
下面測試下中文文本的序列化和反序列化。
text_zh = "在查處虛開增值稅專用發票案件中,常常涉及進項留抵稅額和稅款損失的認定和處理。"
token_ids_zh = tokenizer_trained.encode(text_zh)
token_ids_zh
[368,1698,1319,4304,953,30571,2147,411,646,3917,6723,413,270,6679,4743,631,1467,3692,9083,3534,2676,315,3534,1805,8576,269,1374,627,12769,286]
tokenizer_trained.decode(token_ids_zh)
'在查處虛開增值稅專用發票案件中,常常涉及進項留抵稅額和稅款損失的認定和處理。'
我們剛訓練的分詞器在中文和英文上都能正常進行的文本的序列化和反序列化操作。
小結:本文借助huggingface提供的tokenizers框架,以一個真實的語料庫為案例,演示了分詞器訓練的過程,并最終得到了一個切實可用的分詞器。但tokenizers框架封裝的比較多,所以在訓練過程中對多語言的編碼和解碼部分作了內部實現的剖析和講解,如果你還對其它部分(如BPE算法)感興趣,下面的參考內容或許能為你提供進一步的幫助。
參考閱讀
- 帶你從零認識語言模型
- 手搓BPE算法
- 什么是tokenizer?