LLM 的訓練資料準備至關重要,本文介紹如何透過新增特殊上下文標記<|unk|><|endoftext|>,區分未知單字和不同文字來源,提升模型訓練效果。進一步說明瞭如何使用 BPE 分詞演算法,即使遇到未登入單詞也能有效處理。文章也示範了滑動視窗技術,從文字資料中擷取輸入-目標配對,並利用 PyTorch 的 Dataset 和 DataLoader 建構高效的資料載入器,為 LLM 訓練提供批次資料。

2.4 新增特殊上下文標記

接下來,我們將進一步測試分詞器對於包含未知單字的文字的處理,並討論可以新增哪些特殊的上下文標記,以在訓練過程中為LLM提供更多的上下文資訊。

圖示說明

此圖示展示瞭如何新增特殊標記到詞彙表中,以處理特定的上下文。例如,新增 <|unk|> 標記來代表新的且未知的單字,這些單字並未出現在訓練資料中,因此也不在現有的詞彙表中。此外,我們新增 <|endoftext|> 標記,用於分隔兩個無關的文字來源。

我們可以修改分詞器,使其在遇到不在詞彙表中的單字時使用 <|unk|> 標記。此外,我們還會在無關的文字之間新增一個標記。例如,在使用類別似GPT的LLMs進行訓練時,通常會在每個檔案或書籍之前插入一個標記,以表明雖然這些文字來源是為了訓練而連線起來的,但它們實際上是無關的,如圖2.10所示。這有助於LLM理解這些文字來源之間的界限。

使用特殊符號處理文字資料

在處理多個獨立的文字來源時,我們需要在這些文字之間新增特殊的標記符號,例如<|endoftext|>,以便大語言模型(LLM)能夠更有效地處理和理解這些文字。

更新詞彙表以包含特殊符號

首先,我們需要更新詞彙表,以包含兩個特殊的符號:<|endoftext|><|unk|>。我們可以透過將這兩個符號新增到所有唯一單詞的列表中來實作這一點:

all_tokens = sorted(list(set(preprocessed)))
all_tokens.extend(["<|endoftext|>", "<|unk|>"])
vocab = {token: integer for integer, token in enumerate(all_tokens)}
print(len(vocab.items()))

內容解密:

  1. all_tokens 列表包含了所有預處理後的唯一單詞。
  2. 使用 extend 方法將 <|endoftext|><|unk|> 新增到 all_tokens 列表中。
  3. 建立詞彙表 vocab,將每個單詞對映到一個整數索引。
  4. 列印詞彙表的大小,以確認更新後的詞彙表包含新的特殊符號。

輸出結果顯示,更新後的詞彙表大小為 1,132,比之前的 1,130 多了兩個條目。

驗證更新後的詞彙表

接下來,我們可以列印更新後的詞彙表的最後五個條目,以驗證新的特殊符號是否已成功新增:

for i, item in enumerate(list(vocab.items())[-5:]):
    print(item)

輸出結果如下:

('younger', 1127)
('your', 1128)
('yourself', 1129)
('<|endoftext|>', 1130)
('<|unk|>', 1131)

內容解密:

  1. 使用 enumerate 函式遍歷詞彙表的最後五個條目。
  2. 列印每個條目的鍵值對,確認 <|endoftext|><|unk|> 已被正確新增。

更新分詞器以處理未知單詞

現在,我們需要更新分詞器,以處理未知單詞。我們建立了一個新的 SimpleTokenizerV2 類別,該類別繼承了之前的 SimpleTokenizerV1 的功能,並增加了對未知單詞的處理:

class SimpleTokenizerV2:
    def __init__(self, vocab):
        self.str_to_int = vocab
        self.int_to_str = {i: s for s, i in vocab.items()}

    def encode(self, text):
        preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', text)
        preprocessed = [item.strip() 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 ids

    def decode(self, ids):
        text = " ".join([self.int_to_str[i] for i in ids])
        text = re.sub(r'\s+([,.:;?!"()\'])', r'\1', text)
        return text

內容解密:

  1. SimpleTokenizerV2__init__ 方法初始化了兩個字典:str_to_intint_to_str,用於單詞和索引之間的對映。
  2. encode 方法將輸入文字分詞,並將未知單詞替換為 <|unk|>
  3. decode 方法將索引序列轉換迴文字,並進行必要的後處理,以還原原始文字的格式。

測試更新後的分詞器

我們可以使用一個簡單的文字範例來測試更新後的分詞器:

text1 = "Hello, do you like tea?"
text2 = "In the sunlit terraces of the palace."
text = " <|endoftext|> ".join((text1, text2))
print(text)

tokenizer = SimpleTokenizerV2(vocab)
print(tokenizer.encode(text))
print(tokenizer.decode(tokenizer.encode(text)))

輸出結果如下:

Hello, do you like tea? <|endoftext|> In the sunlit terraces of the palace.
[1131, 5, 355, 1126, 628, 975, 10, 1130, 55, 988, 956, 984, 722, 988, 1131, 7]
<|unk|>, do you like tea? <|endoftext|> In the sunlit terraces of the <|unk|>.

內容解密:

  1. 將兩個獨立的句子連線起來,中間使用 <|endoftext|> 分隔。
  2. 使用 SimpleTokenizerV2 對連線後的文字進行編碼和解碼。
  3. 輸出結果顯示,<|endoftext|><|unk|> 已被正確處理。

其他特殊符號的討論

除了 <|endoftext|><|unk|> 之外,還有一些其他的特殊符號可以用於特定的應用場景,例如 [BOS][EOS][PAD]。然而,GPT 模型使用的分詞器只使用 <|endoftext|>,並且使用掩碼來忽略填充的標記,因此填充標記的選擇變得不那麼重要。

2.5 位元組對編碼(Byte Pair Encoding, BPE)

讓我們來看看一個更為複雜的標記化方案,它根據一個稱為位元組對編碼(BPE)的概念。BPE 標記器曾被用於訓練諸如 GPT-2、GPT-3 和原始 ChatGPT 模型等大語言模型(LLM)。

由於實作 BPE 可能相對複雜,因此我們將使用一個現有的 Python 開源函式庫,稱為 tiktoken(https://github.com/openai/tiktoken),它根據 Rust 的原始碼非常高效地實作了 BPE 演算法。與其他 Python 函式庫類別似,我們可以透過 Python 的 pip 安裝程式從終端機安裝 tiktoken 函式庫:

pip install tiktoken

我們將使用的程式碼根據 tiktoken 0.7.0。您可以使用以下程式碼檢查目前安裝的版本:

from importlib.metadata import version
import tiktoken
print("tiktoken 版本:", version("tiktoken"))

安裝完成後,我們可以按如下方式從 tiktoken 例項化 BPE 標記器:

tokenizer = tiktoken.get_encoding("gpt2")

此標記器的用法與我們之前透過 encode 方法實作的 SimpleTokenizerV2 類別似:

text = (
    "Hello, do you like tea? <|endoftext|> In the sunlit terraces"
    "of someunknownPlace."
)
integers = tokenizer.encode(text, allowed_special={"<|endoftext|>"})
print(integers)

該程式碼列印出以下標記 ID:

[15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, 18250, 8812, 2114, 286, 617, 34680, 27271, 13]

然後,我們可以使用 decode 方法將標記 ID 轉換迴文字,類別似於我們的 SimpleTokenizerV2:

strings = tokenizer.decode(integers)
print(strings)

該程式碼列印出:

Hello, do you like tea? <|endoftext|> In the sunlit terraces of someunknownPlace.

內容解密:

  1. BPE 標記器特性:BPE 標記器具有處理未知詞彙的能力,無需使用 <|unk|> 等特殊標記。
  2. 詞彙分解:BPE 將不在其預定義詞彙表中的詞分解為較小的子詞或個別字元,從而能夠處理超出詞彙表的詞彙。
  3. 詞彙構建:BPE 透過迭代合併頻繁出現的字元對來構建其詞彙表,從單個字元開始,逐步形成子詞和詞彙。

練習 2.1:使用 BPE 對未知詞進行編碼

嘗試使用來自 tiktoken 函式庫的 BPE 標記器對未知詞 “Akwirw ier” 進行編碼,並列印出個別的標記 ID。然後,對每個結果整數呼叫 decode 函式,以重現圖 2.11 中的對映。最後,對標記 ID 呼叫 decode 方法,以檢查是否能夠重建原始輸入 “Akwirw ier”。

2.6 使用滑動視窗進行資料取樣

建立 LLM 的嵌入表示的下一步是生成訓練 LLM 所需的輸入-目標對。這些輸入-目標對是什麼樣的?正如我們已經瞭解的那樣,LLM 是透過預測文字中的下一個詞來進行預訓練的,如圖 2.12 所示。

讓我們實作一個資料載入器,使用滑動視窗方法從訓練資料集中取得圖 2.12 中的輸入-目標對。首先,我們將使用 BPE 標記器對整個“The Verdict”短篇小說進行標記化處理:

with open("the-verdict.txt", "r", encoding="utf-8") as f:
    raw_text = f.read()
enc_text = tokenizer.encode(raw_text)
print(len(enc_text))

執行此程式碼將傳回 5145,即在應用 BPE 標記器後訓練集中的標記總數。

接下來,為了演示目的,我們從資料集中刪除了前 50 個標記,因為這會在接下來的步驟中產生一個稍微更有趣的文欄位落:

enc_sample = enc_text[50:]

內容解密:

  1. 資料準備:使用 BPE 對文字進行標記化,以準備訓練資料。
  2. 滑動視窗方法:透過滑動視窗方法生成輸入-目標對,用於訓練 LLM。
  3. 輸入-目標對:LLM 的訓練任務是根據給定的輸入預測下一個詞。

2.6 利用滑動視窗進行資料取樣

在進行下一詞預測任務時,建立輸入-目標配對的一種直觀方法是建立兩個變數,x 和 y,其中 x 包含輸入的 tokens,而 y 包含目標,即向後偏移 1 的輸入:

context_size = 4
x = enc_sample[:context_size]
y = enc_sample[1:context_size+1]
print(f"x: {x}")
print(f"y: {y}")

執行上述程式碼後輸出如下:

x: [290, 4920, 2241, 287]
y: [4920, 2241, 287, 257]

透過處理輸入及其目標(即向後偏移一個位置的輸入),可以建立下一詞預測任務,如圖 2.12 所示。程式碼如下:

for i in range(1, context_size+1):
    context = enc_sample[:i]
    desired = enc_sample[i]
    print(context, "
---
->", desired)

輸出結果為:

[290] 
---
-> 4920
[290, 4920] 
---
-> 2241
[290, 4920, 2241] 
---
-> 287
[290, 4920, 2241, 287] 
---
-> 257

箭頭左側代表 LLM 將接收的輸入,而箭頭右側的 token ID 代表 LLM 需要預測的目標 token ID。接下來,將 token ID 轉換為文字:

for i in range(1, context_size+1):
    context = enc_sample[:i]
    desired = enc_sample[i]
    print(tokenizer.decode(context), "
---
->", tokenizer.decode([desired]))

輸出結果如下:

and 
---
-> established
and established 
---
-> himself
and established himself 
---
-> in
and established himself in 
---
-> a

現在已經建立了可用於 LLM 訓練的輸入-目標配對。在將 tokens 轉換為嵌入表示之前,還需要實作一個高效的資料載入器,以迭代輸入資料集並傳回 PyTorch tensors(可視為多維陣列)的輸入和目標。

資料載入器的實作

為了實作高效的資料載入器,將使用 PyTorch 的內建 DatasetDataLoader 類別。程式碼如下:

import torch
from torch.utils.data import Dataset, DataLoader

class GPTDatasetV1(Dataset):
    def __init__(self, txt, tokenizer, max_length, stride):
        self.input_ids = []
        self.target_ids = []
        token_ids = tokenizer.encode(txt)
        
        for i in range(0, len(token_ids) - max_length, stride):
            input_chunk = token_ids[i:i + max_length]
            target_chunk = token_ids[i + 1: i + max_length + 1]
            self.input_ids.append(torch.tensor(input_chunk))
            self.target_ids.append(torch.tensor(target_chunk))
    
    def __len__(self):
        return len(self.input_ids)
    
    def __getitem__(self, idx):
        return self.input_ids[idx], self.target_ids[idx]

程式碼解密:

  1. __init__ 方法:初始化 GPTDatasetV1 物件,將文字資料進行分詞並轉換為 token IDs,然後利用滑動視窗的方式建立輸入和目標配對。
  2. tokenizer.encode(txt):將輸入文字編碼為 token IDs。
  3. for 迴圈:使用滑動視窗(stride)來切割 token IDs,生成多個輸入和目標配對。
  4. __len__ 方法:傳回資料集中的樣本數量。
  5. __getitem__ 方法:根據索引傳回對應的輸入和目標 tensor。

使用 GPTDatasetV1 建立資料載入器

def create_dataloader_v1(txt, batch_size=4, max_length=256,
                          stride=128, shuffle=True, drop_last=True,
                          num_workers=0):
    tokenizer = tiktoken.get_encoding("gpt2")
    dataset = GPTDatasetV1(txt, tokenizer, max_length, stride)
    dataloader = DataLoader(
        dataset,
        batch_size=batch_size,
        shuffle=shuffle,
        drop_last=drop_last,
        num_workers=num_workers
    )
    return dataloader

程式碼解密:

  1. create_dataloader_v1 函式:建立一個批次資料載入器,用於載入輸入和目標配對。
  2. tokenizer = tiktoken.get_encoding("gpt2"):初始化 GPT-2 的 tokenizer。
  3. dataset = GPTDatasetV1(txt, tokenizer, max_length, stride):建立 GPTDatasetV1 物件,用於管理資料。
  4. DataLoader:將資料集封裝為批次載入器,可以指定批次大小、是否亂序等引數。
  5. drop_last=True:丟棄最後一個不完整的批次,以避免訓練過程中出現長度不一致的批次。