1. 前言
神經網絡不能直接處理自然語言文本,文本數據處理的核心是做tokenization,將自然語言文本分割成一系列tokens。
本文介紹tokenization的基本原理,OpenAI的GPT系列大語言模型使用的tokenization方法——字節對編碼(BPE, byte pair encoding),并構建訓練大語言模型的Dataset
及DataLoader
。
2. Tokenization
Tokenization的目標是將自然語言文本分割成一系列tokens。在英文自然語言處理領域,最常用的tokenization方法是將英文文本分割成單詞及標點符號。在中文自然語言處理領域,往往會將一個字或一個標點符號作為一個token。
如下面的代碼所示,可以使用Python自帶的正則表達式庫re
將英文文本分割成不同的單詞及標點符號:
import resentence = "Hello, world. This, is a test."
tokens = re.split(r'([,.?_!"()\']|\s)', sentence)
tokens = [item for item in tokens if item.strip()]
print(tokens)
執行上面代碼,打印結果如下:
['Hello', ',', 'world', '.', 'This', ',', 'is', 'a', 'test', '.']
上面的tokenization示例代碼刪除掉了文本中的空格。在實際應用中,也可以將空格視為一個單獨的字符。刪除空格可以顯著減少分割文本產生的tokens數量,減少內存消耗,降低計算資源需求。但是在構建代碼生成模型等應用場景中,必須將文本中的空格視為一個單獨的字符,并編碼成相應的token。
3. 將Token轉換成數字ID
絕大部分處理自然語言文本的神經網絡都包含Embedding層(embedding layer)torch.nn.Embedding
。Embedding層存儲了不同tokens對應的embedding向量,輸入一系列tokens對應的索引列表,Embedding層輸出相應tokens對應的embedding向量。
對自然語言文本做tokenization,將文本分割成不同的tokens。Tokens不能直接輸入神經網絡的Embedding層,需要將tokens轉換成數字ID(token ID)。將tokens轉換成數字ID首先要構造詞匯表(vocabulary)。詞匯表定義了不同tokens與數字索引的一一對應關系,可以使用詞匯表將分割自然語言文本產生的一系列tokens轉換成數字ID列表,也可以通過詞匯表將數字ID列表還原成自然語言文本。
如下圖所示,構造詞匯表需要對訓練數據集中全部文本數據做tokenization,獲取所有不同的tokens,并一一添加到字典(dict)中。字典的key為訓練數據集中的不同tokens,字典的value為相應token添加到字典中的順序。
可以使用如下代碼構造詞匯表:
diff_tokens = sorted(set(tokens))
vocabulary = {token: idx for idx, token in enumerate(diff_tokens)}for item in vocabulary.items():print(item)
執行上面代碼,打印結果如下:
(',', 0)
('.', 1)
('Hello', 2)
('This', 3)
('a', 4)
('is', 5)
('test', 6)
('world', 7)
4. 常用的特殊Tokens
上述對訓練數據集中全部文本數據做tokenization,獲取所有不同的tokens,并構造將token映射成數字ID的詞匯表的文本數據處理方法無法將訓練數據集中不存在的token轉換成數字ID。在自然語言處理項目實踐中,往往會在詞匯表中增加一些特殊tokens,以增強模型理解自然語言文本結構等信息的能力。常用的特殊tokens如下所示:
<|unk|>
(unknown):該token一般用于表示不在構建的詞匯表中的單詞或字符<|endoftext|>
(end of text):該token一般用于分割兩個不相關的文本。訓練大語言模型的文本數據一般由許多文本拼接而成,不同文本之間使用<|endoftext|>
分隔,使大語言模型可以理解訓練數據的組織方式[BOS]
(beginning of sequence):該token通常位于一段文本的開頭,用于給模型提供輸入文本的組織結構信息[EOS]
(end of sequence):該token通常位于一段文本的末尾,用于拼接多個不相關的文本,其作用與<|endoftext|>
類似[PAD]
(padding):訓練大語言模型每次會使用一個batch的訓練樣本,構成一個張量(tensor),張量內所有訓練樣本token數量必須相同。如果batch中包含不同長度的訓練樣本,一般會使用[PAD]
將較短的訓練樣本填充至batch中最長訓練樣本長度
GPT-2系列大語言模型只在詞匯表中增加了
<|endoftext|>
這個特殊token。其使用了一種被稱為字節對編碼(byte pair encoding)的tokenization方法,該方法分割文本不會產生詞匯表不包含的新token。
可以將上述tokenization方法封裝成一個Tokenizer
類,其中encode
函數用于將自然語言文本分割成一系列tokens,并轉換成數字ID列表。decode
函數用于將數字ID列表還原成自然語言文本:
class Tokenizer:def __init__(self, vocabulary):self.str_to_int = vocabularyself.int_to_str = list(vocabulary.keys())def encode(self, text):preprocessed = re.split(r'([,.?_!"()\']|\s)', text)preprocessed = [item for item in preprocessed if item.strip()]preprocessed = [item if item in self.str_to_int else "<|unk|>" for item in preprocessed]ids = [self.str_to_int[s] for s in preprocessed]return idsdef decode(self, ids):text = " ".join([self.int_to_str[i] for i in ids])text = re.sub(r'\s+([,.?!"()\'])', r'\1', text)return text
可以使用如下代碼實例化Tokenizer
類對象,并調用encode
及decode
函數,打印示例文本對應的tokens及相應文本內容:
vocabulary.update({"<|unk|>": len(vocabulary), "<|endoftext|>": len(vocabulary) + 1})
text = "Hello world. <|endoftext|> This is a test dataset."tokenizer = Tokenizer(vocabulary)
print(tokenizer.encode(text))
print(tokenizer.decode(tokenizer.encode(text)))
執行上面代碼,打印結果如下:
[2, 7, 1, 9, 3, 5, 4, 6, 8, 1]
Hello world. <|endoftext|> This is a test <|unk|>.
5. 字節對編碼(Byte Pair Encoding)
字節對編碼(BPE)是大語言模型GPT-2、GPT-3以及ChatGPT使用的tokenization方法。BPE會將所有單個字符(如"a","b"等)以及頻繁出現的字符組合(subtoken)添加到詞匯表中(如字符"d"和"e"的組合"de"在許多英文單詞中很常見,因此會將"de"添加到詞匯表中作為一個token)。
如下圖所示,BPE可以將不在其詞匯表中的單詞分解成粒度更小的字符或字符組合,因此OpenAI的GPT系列模型不用<|unk|>
等特殊token來處理不在其詞匯表中的單詞。
OpenAI開源了其使用Rust語言實現的非常高效的BPE算法tiktoken庫,可以使用如下命令安裝tiktoken:
!pip install tiktoken==0.5.1
使用tiktoken.encoding_for_model
方法創建tokenizer對象,加載大語言模型GPT-2所使用的詞匯表,并調用encode
及decode
函數,測試BPE算法:
import tiktokentokenizer = tiktoken.encoding_for_model("gpt2")
text = "Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace."
integers = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
print(integers)
strings = tokenizer.decode(integers)
print(strings)
執行上面代碼,打印結果如下:
[15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, 18250, 8812, 2114, 286, 617, 34680, 27271, 13]
Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace.
創建tokenizer對象并加載大語言模型GPT-3.5所使用的詞匯表,可以使用
tokenizer = tiktoken.encoding_for_model("gpt-3.5-turbo")
方法。加載大語言模型GPT-4所使用的詞匯表,可以使用tokenizer = tiktoken.encoding_for_model("gpt-4")
。查看tiktoken的內部源碼可知,大語言模型GPT-3.5及GPT-4所使用的詞匯表完全相同。
6. 構建訓練大語言模型的Dataset及DataLoader
6.1 大語言模型的訓練樣本
預訓練大語言模型的數據集包含許多自然語言文檔,不同文檔的內容使用<|endoftext|>
拼接,構成一個非常長的文本字符串,使用上述BPE算法可以將該字符串轉變成數字ID列表。
使用下一個token預測任務預訓練大語言模型,從數字ID列表中隨機抽取一個長度為context_len
的子列表輸入大語言模型。大語言模型可以執行context_len
次下一個token預測任務,共預測輸出context_len
個tokens。
如下圖所示,假設context_len
等于8,則第一次下一個token預測任務大模型根據輸入LLMs
預測learn
,第二次根據輸入LLMs learn
預測to
。依此類推,第八次根據輸入LLMs learn to predict one word at a
預測time
。
在實際訓練大語言模型時,會輸入長度為
context_len
的數字ID列表,每個數字對應一個token,而不是輸入一個字符串。
因此可以使用如下規則構建訓練大語言模型的訓練樣本。每個訓練樣本包含一個input-target pair
,其中:
- input:從訓練數據集對應的數字ID列表中隨機抽取的長度為
context_len
的子列表 - target:將input子列表向后移一個token對應的子列表,其中target中第 i i i個token ID為input中第 i ? 1 i-1 i?1個token ID
理論上,長度為 n n n的token ID列表對應的訓練數據集最多可以構造出 n ? context_len + 1 n-\text{context\_len}+1 n?context_len+1個不同的訓練樣本。實際上,會使用stride
控制兩個相鄰訓練樣本之間的重疊tokens數量。如果 stride = = 1 \text{stride}==1 stride==1,則兩個相鄰的訓練樣本存在個 context_len ? 1 \text{context\_len}-1 context_len?1重疊的tokens,第 i i i個訓練樣本的target
即為第 i + 1 i+1 i+1個訓練樣本的input
。如果 stride = = context_len \text{stride}==\text{context\_len} stride==context_len,則兩個相鄰訓練樣本之間不存在重疊的tokens。stride
越小,則可以構造出更多訓練樣本,但是模型更容易過擬合。stride
越大,模型越不容易過擬合,但是同一個訓練數據集中構造的訓練樣本數會更少。
6.2 構建Dataset
Pytorch提供的高效組織及加載訓練數據的模塊由Dataset
及DataLoader
類構成,其中Dataset
類用于定義如何加載每個訓練樣本,DataLoader
類用于隨機打亂訓練數據集,并將訓練數據集劃分到不同的batch。
構建訓練數據集對應的Dataset
,需要實現一個Dataset
的子類,并重寫__init__
構造方法,__getitem__
方法,以及__len__
方法。__init__
方法用于初始化與訪問訓練數據相關的屬性,如文件路徑、數據庫連接器等等。如果訓練數據集不是特別大,可以在__init__
方法中將整個訓練數據集全部讀取到內存中。如果訓練數據集特別大,無法一次全部加載進內存,一般只會在__init__
方法中初始化數據文件路徑,后續實際要用到相應數據時才會去讀取。__getitem__
中實現了根據索引返回相應訓練樣本的方法。在DataLoader
類的實例化對象獲取一個batch的訓練樣本時,__getitem__
方法會被調用batch_size
次,共傳入batch_size
個不同的索引,返回batch_size
個不同的訓練樣本,構成一個訓練大語言模型的batch。__len__
方法定義了訓練數據集的大小,假設__len__
方法返回訓練數據集的大小為10,則每次調用__getitem__
方法傳入的索引均為0-9
之間(包括0和9)的整數。
綜上所述,可以使用如下代碼構建訓練大語言模型的Dataset
:
import os
import json
import randomimport torch
import tiktoken
from torch.utils.data import Datasetclass LLMDataset(Dataset):def __init__(self, data_path, file_index_dir, vocabulary, special_token_id, context_len, stride):self.tokenizer = tiktoken.encoding_for_model(vocabulary)self.special_token_id = special_token_idself.context_len = context_lenself.stride = strideif not os.path.exists(file_index_dir):os.makedirs(file_index_dir, exist_ok=True)file_index_path = os.path.join(file_index_dir, "file_index.json")if not os.path.isfile(file_index_path):support_file_paths = []for root, dirs, files in os.walk(data_path):for file in files:if file.endswith((".txt", ".md")):file_path = os.path.join(root, file)support_file_paths.append(file_path)random.shuffle(support_file_paths)file_indexes, total_tokens = dict(), 0for file_path in support_file_paths:with open(file_path, "rt", encoding="utf-8") as f:file_token = self.tokenizer.encode(f.read(), allowed_special=self.tokenizer.special_tokens_set)file_indexes[file_path] = [total_tokens, total_tokens + len(file_token)]total_tokens += len(file_token) + 1with open(file_index_path, "wt", encoding="utf-8") as f:json.dump(file_indexes, f, ensure_ascii=False)with open(file_index_path, "rt", encoding="utf-8") as f:self.file_index = json.load(f)max_token_index = list(self.file_index.values())[-1][1]self.num_sample = (max_token_index - context_len) // stride + 1def __getitem__(self, index):index_files = self._index_files(index)start_index = index * self.stridestop_index = start_index + self.context_lensample = []for file_path in index_files:index_range = self.file_index[file_path]file_start, file_end = 0, index_range[1] - index_range[0] + 1if index_range[0] <= start_index <= index_range[1]:file_start = start_index - index_range[0]if index_range[0] <= stop_index <= index_range[1]:file_end = stop_index - index_range[0] + 1with open(file_path, "rt", encoding="utf-8") as f:tokens = self.tokenizer.encode(f.read(), allowed_special=self.tokenizer.special_tokens_set)tokens.append(self.special_token_id)sample.extend(tokens[file_start: file_end])return torch.tensor(sample[:-1]), torch.tensor(sample[1:])def __len__(self):return self.num_sampledef _index_files(self, index):index_files = []start_file, stop_file = None, Nonestart_index = index * self.stridestop_index = start_index + self.context_lenfor file_path, index_range in self.file_index.items():if index_range[0] <= start_index <= index_range[1]:start_file = file_pathif start_file is not None:index_files.append(file_path)if index_range[0] <= stop_index <= index_range[1]:stop_file = file_pathif stop_file is not None:breakreturn index_files
LLMDataset
類的__init__
方法中通過self.tokenizer = tiktoken.encoding_for_model(vocabulary)
初始化了將文本轉換為token ID的BPE tokenizer。遍歷整個訓練數據集中的.txt
及.md
文件,初始化了一個key為文件路徑,value為文件中全部token的[起始索引, 終止索引]
列表的字典self.file_index
,用于在__getitem__
方法中根據索引找到相應訓練樣本所在文件。初始化了記錄訓練數據集中可構造訓練樣本總數的變量self.num_sample
。
__getitem__
方法調用self._index_files(index)
函數,返回index
對應的訓練樣本所分布的文件路徑。遍歷文件路徑列表index_files
,從各個文件中取出屬于index
對應訓練樣本部分的tokens,并組合成訓練樣本sample
。
上述訓練大語言模型的
LLMDataset
并沒有將訓練數據一次全部加載進內存,只在__init__
方法中記錄了訓練數據文件,并在調用__getitem__
方法時根據index
實時讀取所需文件,構造訓練樣本。這種方法可以不用將全部訓練數據加載進內存,但是需要耗費一定時間完成訓練樣本構造。構建訓練大語言模型的DataLoader時,可以通過設置
num_workers
,使數據讀取與模型訓練并行進行。只要LLMDataset
中的訓練數據構造效率不是特別慢,一般不會影響模型訓練效率。
如果訓練大語言模型的計算服務器集群內存足夠大到可以將整個訓練數據集一次性全部加載進內存,構建訓練大語言模型的Dataset
時,可以在__init__
方法中讀取訓練數據集中的全部.txt
及.md
文檔,將不同文檔的內容使用<|endoftext|>
拼接,構成一個非常長的文本字符串,并使用BPE tokenizer分別將該字符串轉變成token ID列表,存入計算服務器集群的內存。
雖然可以在構建訓練大語言模型的DataLoader時,通過設置num_workers
,使數據讀取與模型訓練并行進行,一定程度上避免訓練數據構造效率對模型訓練的影響。但是在內存資源充足的情況下,直接在__init__
方法中將整個訓練數據集全部加載進內存,從而提升__getitem__
方法中根據指定index構造訓練數據的速度,可以使模型訓練的整體效率至少不比使用上面構建的Dataset
差。具體代碼如下所示:
class LLMDataset(Dataset):def __init__(self, data_path, vocabulary, special_token_id, context_len, stride):self.context_len = context_lenself.stride = stridesupport_file_paths = []for root, dirs, files in os.walk(data_path):for file in files:if file.endswith((".txt", ".md")):file_path = os.path.join(root, file)support_file_paths.append(file_path)random.shuffle(support_file_paths)self.tokens = []tokenizer = tiktoken.encoding_for_model(vocabulary)for file_path in support_file_paths:with open(file_path, "rt", encoding="utf-8") as f:file_token = tokenizer.encode(f.read(), allowed_special=tokenizer.special_tokens_set)file_token.append(special_token_id)self.tokens.extend(file_token)self.num_sample = (len(self.tokens) - context_len - 1) // stride + 1def __getitem__(self, index):start_index = index * self.stridex = self.tokens[start_index: start_index + self.context_len]y = self.tokens[start_index + 1: start_index + self.context_len + 1]return torch.tensor(x), torch.tensor(y)def __len__(self):return self.num_sample
6.3 構建DataLoader
構建分batch讀取訓練數據的DataLoader
,只需要傳入一個Dataset
對象,并實例化DataLoader
類對象。可以使用如下代碼構建訓練大語言模型的DataLoader
:
from torch.utils.data import DataLoaderbatch_size = 16
random_seed = 123torch.manual_seed(random_seed)dataset = LLMDataset(data_path="some_data_folder_path")
train_loader = DataLoader(dataset=dataset,batch_size=batch_size,shuffle=True,num_workers=4,drop_last=True
)
shuffle
參數用于控制是否隨機打亂訓練數據集。如果shuffle
設置為False
,DataLoader
會依據索引從小到大的順序依次生成不同batch的訓練數據。如果shuffle
設置為True
,DataLoader
會隨機打亂所有訓練樣本的索引,并按照隨機打亂后的索引順序依次生成不同batch的訓練數據。一般會將訓練數據集對應DataLoader
的shuffle
參數設置為True
,確保不同batch的訓練數據是獨立同分布的。測試數據集對應DataLoader
的shuffle
參數一般會設置為False
,因為測試數據集中數據不被用于訓練模型,保存數據測試順序信息有助于分析數據測試結果。
通過torch.manual_seed(random_seed)
指定隨機數種子,可以使DataLoader
在不同次訓練流程中生成完全相同的訓練樣本索引隨機排列,但是一次訓練流程的對訓練數據集的不同次迭代中,訓練樣本索引的隨機排列會各不相同。設置隨機數種子有助于神經網絡訓練結果復現,但是不會使得在訓練過程中陷入重復的更新周期。
假設訓練數據集共包含5個不同的訓練樣本,構建
DataLoader
時設置shuffle
參數為True
,則在一次訓練流程的前3次對訓練數據集的遍歷過程中,訪問訓練數據的順序可能如下(不同隨機數種子會產生不同的訪問順序):
- 第一次遍歷訓練數據集的順序:[3, 4, 1, 0, 2]
- 第二次遍歷訓練數據集的順序:[2, 1, 0, 3, 4]
- 第三次遍歷訓練數據集的順序:[1, 4, 0, 3, 2]
保持隨機數種子不變,第二次執行訓練代碼,在第二次訓練流程中的前3次對訓練數據集的遍歷順序必定與上面的遍歷順序相同。
如果在訓練大語言模型時程序異常中斷,從保存的斷點(checkpoint)處恢復訓練環境,需要特別注意隨機數種子的設置與變更。如果從某個batch對應的checkpoint恢復訓練環境,只需要使用同一個隨機數種子,并跳過前 k k k個已經訓練的batch即可。如果從某個epoch對應的checkpoint處繼續訓練模型,需要變更隨機數種子,確保新的一輪訓練遍歷訓練數據集的順序與上一次遍歷訓練數據集的順序不一致。在訓練大語言模型時,建議不同epoch使用不同的隨機數種子,并記錄隨機數種子的使用順序。
batch_size
是指訓練大語言模型的一個batch中包含訓練樣本的數量。batch_size
越小,則訓練大語言模型要求的顯卡最大內存越小,但是會導致計算出的更新大語言模型的梯度方差較大,影響大語言模型訓練時的收斂速度及模型最終效果。batch_size
的設置可以參考OpenAI訓練GPT系列大語言模型的論文,或者設置成當前顯卡內存資源允許的最大值。
在實際訓練大語言模型時,訓練數據集中的訓練樣本數量一般不太可能恰好構成整數個batch,最后一個batch很可能僅包含相對非常少的訓練樣本。在一個訓練的epoch中,使用包含訓練樣本數量非常少的batch作為最后一個batch會引入一次噪聲較大的更新梯度,影響訓練大語言模型時的收斂效果。將drop_last
設置為True
,會將每個epoch中的最后一個batch丟棄,不參與模型參數更新。
num_workers
參數用于控制數據并行加載及預處理。如下圖所示,在使用GPU訓練大語言模型時,CPU不僅要與GPU交互處理深度學習模型參數調度等任務,還要加載及預處理訓練數據。如果num_workers=0
,系統將使用主進程加載數據,數據處理與GPU任務調度時串行的,GPU在CPU加載及預處理訓練數據時處于空閑狀態,會明顯降低模型訓練速度及GPU利用率。如果num_workers
大于0,系統將啟動多個工作進程并行加載及預處理訓練數據,使主進程專注于GPU資源及訓練任務調度。num_workers
必須根據系統計算資源及訓練數據集情況來確定,根據實踐經驗,大部分情況下將num_workers
設置為4可以比較高效地利用系統計算資源。
7. 結束語
本文詳細講解了文本數據處理的方法,并構建了訓練大語言模型的Dataset
及DataLoader
。請坐好站穩,我們將要去深入了解大語言模型的神經網絡架構了!