本章介紹
- 為大語言模型的訓練準備文本數據集
- 將文本分割成詞和子詞token
- 字節對編碼(Byte Pair Encoding,BPE):一種更為高級的文本分詞技術
- 使用滑動窗口方法采樣訓練示例
- 將tokens轉換為向量,輸入到大語言模型中
文章目錄
- 本章介紹
- 2.1 理解詞嵌入
- 2.2 文本分詞
- 2.3 將tokens轉換為token IDs
- 2.4 添加特殊上下文token
- 2.5 字節對編碼(Byte Pair Encoding)
- 2.6 使用滑動窗口進行數據采樣
- 2.7 構建詞嵌入層
- 2.8 位置編碼
- 2.9 本章摘要
在上一章,介紹了LLM的基本結構,并了解到他們會基于海量的文本數據集進行預訓練。我們特別關注的是僅使用通用Transformer架構中的解碼器部分LLM,這也是chagpt和其他流行的類似GPT的LLM所依賴的模型。
在預訓練階段,LLM逐字處理文本。通過使用下一個單詞預測任務訓練擁有數百萬到數十億參數的 LLM,最終能夠生成具有出色能力的模型。這些模型隨后可以進一步微調,以遵循指令或執行特定目標任務。然而,在我們接下來幾章中實現和訓練 LLM 之前,我們需要準備訓練數據集,這也是本章的重點,如圖 2.1 所示。
在本章中,將學習如何為訓練 LLM 準備輸入文本。這包括將文本拆分為單個單詞和子詞token,并將這些token編碼為 LLM 的向量表示。您還將了解一些先進的token分割方案,比如字節對編碼,這種方法在像 GPT 這樣的流行 LLM 中得到應用。最后,我們將實現一個采樣和數據加載策略,以生成后續章節中訓練 LLM 所需的輸入輸出數據對。
2.1 理解詞嵌入
深度神經網絡模型,包括 LLM,往往無法直接處理原始文本。這是因為文本是離散的分類數據,它與實現和訓練神經網絡所需的數學運算不兼容。因此,我們需要一種方法將單詞表示為連續值向量。
Note
1.張量:一個將向量和矩陣向更高維度的推廣的數學概念。換句話說,張量是可以用它們的階(或秩)來描述的數學對象,階(或秩)表示了張量的維度數量。例如,一個標量(就是一個數字)是 0 階張量,一個向量是 1 階張量,一個矩陣是 2 階張量,如圖 A.6 所示。
從計算的角度來看,張量充當數據容器。例如,它們可以存儲多維數據,其中每個維度代表一個不同的特征。張量庫(例如 PyTorch)可以高效地創建、操作和計算這些多維數組。在這種情況下,張量庫的作用類似于數組庫。
2.如前所述,PyTorch 張量是用于存儲類似數組結構的數據容器。標量是 0 維張量(例如,一個簡單的數字),向量是 1 維張量(如[1, 2, 3]),而矩陣是 2 維張量(如[[1, 2], [3, 4]])。對于更高維度的張量沒有特定的術語,所以我們通常將 3 維張量稱為 3D 張量,以此類推。
將數據轉換為向量格式的過程通常被稱為嵌入(Embedding)。我們可以通過特定的神經網絡層或其他預訓練的神經網絡模型來對不同類型的數據進行嵌入,比如視頻、音頻和文本,如圖 2.2 所示。
我們可以使用嵌入模型來處理多種不同的數據格式。然而,需要注意的是,不同的數據格式需要使用不同的嵌入模型。例如,專為文本設計的嵌入模型并不適用于音頻或視頻數據的嵌入。
💡 Tip
**個人思考:**不同格式的數據源(如文本、圖像、音頻、視頻)在處理和嵌入時,需要不同的模型和技術,原因在于它們的數據結構、特征和處理方式各不相同,因此需要針對性的方法將這些不同的數據類型轉換為適合神經網絡處理的向量表示。以下總結了不同數據源在嵌入時的一些區別:
數據類型 數據特征 嵌入模型 主要特征 文本 離散的、序列化的符號數據 Word2Vec, GloVe, BERT, GPT 等 語義關系、上下文理解 圖像 二維像素網格,具有空間特征 CNN(ResNet、VGG)、ViT 形狀、紋理、顏色等視覺特征 音頻 一維時序信號 CNN+頻譜圖、RNN、Transformer 頻率、音調、時序依賴 視頻 時空序列數據 3D CNN、RNN+CNN、Video Transformer 時空特征、動作捕捉
嵌入的本質是將離散對象(如單詞、圖像或整個文檔)映射到連續向量空間中的點。嵌入的主要目的是將非數值數據轉換為神經網絡能夠處理的格式。
雖然單詞嵌入是最常用的文本嵌入形式,但也存在句子、段落或整篇文檔的嵌入。句子和段落嵌入常被用于檢索增強生成技術。檢索增強生成結合了文本生成與從外部知識庫中檢索相關信息的過程,這是一種超出本書討論范圍的技術。由于我們希望訓練類似于GPT的LLM,這類模型以逐字的方式生成文本,因此本章將重點放在單詞嵌入上。
Tip
個人思考:
檢索增強技術(RAG),目前已經廣泛應用于特定領域的知識問答場景。盡管GPT在文本生成任務中表現強大,但它們依賴的是預訓練的知識,這意味著它們的回答依賴于模型在預訓練階段學習到的信息。這種方式導致了幾個問題:
- 知識的時效性: 模型的知識基于它的預訓練數據,因此無法獲取最新的信息。比如,GPT-3 的知識截止到 2021 年,無法回答最新的事件或發展。
- 模型大小的限制: 即使是大型模型,所能存儲和運用的知識也是有限的。如果任務涉及特定領域(如醫學、法律、科學研究),模型在預訓練階段可能沒有涵蓋足夠的信息.
- 生成的準確性: 生成模型可能會憑空編造信息(即“幻覺現象”),導致生成內容不準確或虛假。
RAG大致原理為將外部知識庫(如文檔、數據庫、互聯網等)進行向量化后存入到向量數據庫中。當用戶提交一個查詢時,首先將這個查詢也編碼成一個向量,然后去承載外部知識庫的向量數據庫中檢索(檢索技術有很多種)與問題相關的信息。檢索到的信息被作為額外的上下文信息輸入到LLM中,LLM會將這些外部信息與原始輸入結合起來,以更準確和豐富的內容生成回答。
生成單詞嵌入的算法和框架有很多。其中,Word2Vec是較早且最受歡迎的項目之一。Word2Vec通過預測給定目標詞的上下文或反之,訓練神經網絡架構以生成單詞嵌入。Word2Vec的核心思想是,出現在相似上下文中的詞通常具有相似的含義。因此,當將單詞投影到二維空間進行可視化時,可以看到相似的詞匯聚在一起,如圖2.3所示。
詞嵌入可以具有不同的維度,從一維到數千維。如圖2.3所示,我們可以選擇二維詞嵌入進行可視化。更高的維度可能捕捉到更細微的關系,但代價是計算效率的降低。
雖然我們可以使用預訓練模型(例如 Word2Vec)為機器學習模型生成嵌入,但 LLM 通常會生成自己的嵌入,這些嵌入是輸入層的一部分,并在訓練過程中進行更新。將嵌入作為 LLM 訓練的一部分進行優化,而不直接使用 Word2Vec,有一個明確的優勢,就是嵌入能夠針對特定的任務和數據進行優化。
高維嵌入在可視化中面臨挑戰,因為我們的感官感知和常見的圖形表示本質上只限于三維或更少的維度,這也是圖 2.3 采用二維散點圖展示二維嵌入的原因。然而,在處理 LLM 時,我們通常使用的嵌入的維度遠高于圖 2.3 所示的維度。對于 GPT-2 和 GPT-3,嵌入的大小(通常稱為模型隱狀態的維度)會根據具體的模型變體和大小而有所不同。這是性能與效率之間的權衡。以具體示例為例,最小的 GPT-2 模型(117M 和 125M 參數)使用 768 維的嵌入大小,而最大的 GPT-3 模型(175B 參數)則使用 12,288 維的嵌入大小。
本章接下來的部分將系統地介紹準備 LLM 使用的嵌入所需的步驟,這些步驟包括將文本拆分為單詞、將單詞轉換為token,以及將token轉化為嵌入向量。
┌────────────┐
│ 原始文本 │
└────┬───────┘│▼
┌────────────┐
│ 文本拆分 │
│(如按句、詞)│
└────┬───────┘│▼
┌────────────┐
│ Token 化 │
│轉換為 Token │
└────┬───────┘│▼
┌────────────┐
│ 嵌入生成 │
│(轉為向量) │
└────┬───────┘│▼
┌────────────┐
│ 輸入到 LLM │
└────────────┘
2.2 文本分詞
本節將討論如何將輸入文本拆分為單個toke。這些token可以是單個單詞或特殊字符,包括標點符號
使用一篇短篇小說《判決》The_Verdict
with open("the-verdict.txt", "r", encoding="utf-8") as f:raw_text = f.read()
print("Total number of character:", len(raw_text))
print(raw_text[:99])
代碼鏈接
具體網址是 第二章代碼鏈接
目標將這篇20479個字符的短篇小說拆分為單詞和特殊字符(統稱為token),在接下來的章節將這些token轉換為 LLM 訓練所需的嵌入。
Note
樣本規模
請注意,在處理 LLM 時,通常會處理數百萬篇文章和數十萬本書——也就是幾 GB 的文本。然而,為了學習,使用像單本書這樣的小文本樣本就足夠了,這樣可以闡明文本處理步驟的主要思想,并能夠在消費級硬件上合理地運行。
1.要如何做才能最好地拆分這段文本以獲得token列表呢?為此,我們來進行一個小小的探討,使用 Python 的正則表達式庫 re 進行說明。
import retext = "Hello, world. This, is a test."
result = re.split(r'(\s)', text)print(result)
執行結果是一個包含單詞、空白和標點符號的列表:
上述簡單的分詞方案僅僅用于將示例文本拆分為單個單詞,然而有些單詞仍然與我們希望單獨列出的標點符號相連。我們也無需將所有文本轉換為小寫字母,因為大寫字母有助于 LLM 區分專有名詞和普通名詞,理解句子結構,并學習生成正確的大寫文本。
2.修改正則表達式,將空白字符(\s)、逗號和句點([,.])單獨拆分出來:
result = re.split(r'([,.]|\s)', text)
print(result)
單詞和標點符號現在已經成為單獨一項,跟我們預期一致:
一個剩余的小問題是列表仍然包含空白字符。我們可以按如下方式安全地刪除這些多余的字符:
result = [item for item in result if item.strip()]
print(result)
關于是否刪除空白字符的探討
開發一個簡單的分詞器時,是否將空白字符編碼為單獨的字符,或者直接將其刪除,取決于我們的應用和需求。刪除空白字符可以減少內存和計算資源的消耗。而,如果我們訓練的模型對文本的確切結構敏感(例如,Python 代碼對縮進和空格非常敏感),那么保留空白字符就很有用。在這里,為了簡化和縮短分詞化輸出,我們選擇刪除空白字符。稍后,我們將切換到一個包含空白字符的分詞化方案。
3.我們上面設計的分詞方案在簡單的示例文本中表現良好。接下來使其能夠處理其他類型的標點符號,如問號、引號,以及在 Edith Wharton 短篇小說的前 100 個字符中看到的雙破折號,還有其他特殊字符:
text = "Hello, world. Is this-- a test?"result = re.split(r'([,.:;?_!"()\']|--|\s)', text)
result = [item.strip() for item in result if item.strip()]
print(result)
我們現在的分詞方案可以成功處理文本中的各種特殊字符
4.我們已經有了一個基本的分詞器,接下來讓我們將其應用于整篇短篇小說:
preprocessed = re.split(r'([,.:;?_!"()\']|--|\s)', raw_text)
preprocessed = [item.strip() for item in preprocessed if item.strip()]
print(preprocessed[:30])
print(len(preprocessed))
4096是小說的token數量(不包括空白字符)
2.3 將tokens轉換為token IDs
上一節將小說分詞為單獨的token,在本節中,我們將把這些token從字符串轉換為整形,以生成所謂的token ID。這一步是將token ID 轉換為嵌入向量的中間步驟。
為了將先前生成的token映射到token ID,首先我們需要構建一個詞匯表。這個詞匯表定義了每個獨特單詞和特殊字符與唯一整數的映射,如圖 2.6 所示。
在前一節中,我們將短篇小說進行分詞,并將其存儲在名為 preprocessed 的 Python 變量中。現在,讓我們創建一個包含所有唯一token的列表,并按字母順序對其進行排序,以確定詞匯表的大小:
all_words = sorted(set(preprocessed))
vocab_size = len(all_words)print(vocab_size)
在通過上述代碼確定詞匯表的大小為 1,130 后,我們通過以下代碼創建詞匯表并打印其前 51 個條目以便于說明:
vocab = {token:integer for integer,token in enumerate(all_words)}
for i, item in enumerate(vocab.items()):print(item)if i >= 50:break
據輸出可知,詞匯表包含了與唯一整數標簽相關聯的單個token。我們接下來的目標是利用這個詞匯表,將新文本轉換為token ID,如圖 2.7 所示。
當我們想將 LLM 的輸出從數字轉換回文本時,我們還需要一種將token ID 轉換為文本的方法。為此,我們可以創建一個詞匯表的反向版本,將token ID 映射回相應的文本token。
讓我們在 Python 中實現一個完整的分詞器類,其中包含一個 encode 方法,該方法負責將文本拆分為token,并通過詞匯表進行token字符串到整數(token ID)的映射,以通過詞匯表生成token ID。此外,我們還將實現一個 decode 方法,該方法則負責進行整數到字符串的反向映射,將token ID 轉換回文本。
該分詞器代碼如下:
# Listing 2.3 Implementing a simple text tokenizer
class SimpleTokenizerV1:def __init__(self, vocab):self.str_to_int = vocab #Aself.int_to_str = {i:s for s,i in vocab.items()} #Bdef encode(self, text): #Cpreprocessed = re.split(r'([,.?_!"()\']|--|\s)', text)preprocessed = [item.strip() for item in preprocessed if item.strip()]ids = [self.str_to_int[s] for s in preprocessed]return idsdef decode(self, ids): #Dtext = " ".join([self.int_to_str[i] for i in ids])text = re.sub(r'\s+([,.?!"()\'])', r'\1', text) #Ereturn text#A 將詞匯表作為類屬性存儲,以方便在 encode 和 decode 方法中訪問
#B 創建一個反向詞匯表,將token ID 映射回原始的文本token
#C 將輸入文本轉換為token ID
#D 將token ID 還原為文本
#E 在指定的標點符號前去掉空格
使用上述的 SimpleTokenizerV1 Python 類,我們現在可以使用現有的詞匯表實例化新的分詞器對象,并利用這些對象對文本進行編碼和解碼,如圖 2.8 所示。
tokenizer = SimpleTokenizerV1(vocab)
text = """"It's the last he painted, you know," Mrs. Gisburn said with pardonable pride."""
ids = tokenizer.encode(text)
print(ids)
輸出的token ID :
通過decode方法將這些token ID轉換為文本:
tokenizer.decode(ids)
2.4 添加特殊上下文token
在上一節中,實現一個簡單的分詞器,并將其應用于訓練集中的一段文本。本節中,修改分詞器來處理未知單詞。
具體來說,將修改在前一節中實現的詞匯表和分詞器類(修改后的類命名為SimpleTokenizerV2),以支持兩個新的token:<|unk|> 和 <|endoftext|>,具體見圖 2.9。
如圖2.9所示,我們可以修改分詞器,以便在遇到不在詞匯表中的單詞時使用一個<|unk|> token。此外,我們還會在不相關的文本之間添加一個特殊的<|endoftext|> token。例如,在對多個獨立文檔或書籍進行GPT類大語言模型的訓練時,通常會在每個文檔或書籍之前插入一個token,以連接前一個文本源,如圖2.10所示。這有助于大語言模型理解,盡管這些文本源在訓練中是連接在一起的,但它們實際上是無關的。
修改詞匯表,將這兩個特殊token <|unk|> 和 <|endoftext|> 包含在內,方法是將它們添加到我們在上一節中創建的唯一單詞列表中:
根據上述輸出,可以確認兩個新的特殊token成功被納入詞匯表,接下來調整分詞器。
class SimpleTokenizerV2:def __init__(self, vocab):self.str_to_int = vocabself.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 idsdef decode(self, ids):text = " ".join([self.int_to_str[i] for i in ids])# Replace spaces before the specified punctuationstext = re.sub(r'\s+([,.:;?!"()\'])', r'\1', text)return text
tokenizer = SimpleTokenizerV2(vocab)text1 = "Hello, do you like tea?"
text2 = "In the sunlit terraces of the palace."text = " <|endoftext|> ".join((text1, text2))print(text)
輸出如下:
Hello, do you like tea? <|endoftext|> In the sunlit terraces of the palace.
通過SimpleTokenizerV2對上述文本進行分詞:
tokenizer.encode(text)
輸出token ID列表:
[1131, 5, 355, 1126, 628, 975, 10, 1130, 55, 988, 956, 984, 722, 988, 1131, 7]
token ID列表包含了1130,他對應于<|endoftext|> 分隔token,以及兩個1131,用于表示未知單詞。
tokenizer.decode(tokenizer.encode(text))
輸出:
'<|unk|>, do you like tea? <|endoftext|> In the sunlit terraces of the <|unk|>.'
通過對上面的去token化文本與原始輸入文本進行比較,我們可以得知訓練數據集,這個短篇下說,并不包含“Hello”和“palace”
到目前為止,討論了分詞作為處理文本輸入到LLM中的重要步驟,根據不同的LLM,還可以考慮其他特殊的token,比如:
- [BOS](序列開始):這個token表示文本的起始位置,指示 LLM 內容的開始。
- [EOS](序列結束):這個token位于文本的末尾,在連接多個無關文本時特別有用,類似于 <|endoftext|>。例如,在合并兩個不同的維基百科文章或書籍時, [EOS] token指示一篇文章結束和下一篇文章開始。
- [PAD](填充):在使用大于 1 的批量大小數據集訓練 LLM 時,批量可能包含不同長度的文本。為了確保所有文本長度一致,較短的文本會用 [PAD] token進行擴展或填充,直到達到批量中最長文本的長度。
用于 GPT 模型的分詞器不需要上述提到的任何token,而只使用 <|endoftext|> token以簡化處理。<|endoftext|> 類似于上面提到的 [EOS] token。此外,<|endoftext|> 也用作填充。然而,在批量輸入的訓練中,我們通常使用掩碼,這意味著我們不會關注填充的token。因此,選擇用于填充的特定token變得無關緊要。
Tip
個人思考
在訓練神經網絡時,通過會將不同長度的句子或者文本批處理為一個batch進行并行訓練。然后不同長度的句子需要補齊到同一長度(基于矩陣運算要求形狀一致)。這時就需要填充token來對齊所有序列的長度,使得模型能夠有效處理不同長度的輸入。掩碼就是一個標志位,用來告訴大模型那些位置需要關注,哪些可以忽略,例如:
- 句子1:“I love NLP.”
- 句子 2:“Transformers are powerful.”
- 句子 3:“GPT is amazing.”
為了將它們放入一個批次,需要將他們填充到相同的長度。假設最長句子的長度為5(token數量),因此每個句子需要填充到5個token。填充時,GPT 使用 <|endoftext|> 作為填充標記。在輸入批次時,我們為每個 token 位置創建一個掩碼矩陣,用來標識哪些位置是有效 token(模型應該關注),哪些是填充 token(模型應該忽略)。假設 1 表示有效 token,0 表示填充 token,則掩碼矩陣如下:
- 句子1(掩碼矩陣):[1, 1, 1, 1, 0]
- 句子2(掩碼矩陣):[1, 1, 1, 1, 0]
- 句子3(掩碼矩陣):[1, 1, 1, 1, 0]
在這個掩碼矩陣中,1 表示模型會關注的 token,0 表示模型會忽略的填充 token。通過這種掩碼矩陣,模型知道在計算和訓練時哪些 token 是有效內容,哪些 token 是填充部分,無需關注。
此外,用于 GPT 模型的分詞器也不使用 <|unk|> 標記來表示詞匯表之外的詞。相反,GPT 模型采用字節對編碼分詞器,它將單詞分解為子詞單元,我們將在下一節中討論這一點。
2.5 字節對編碼(Byte Pair Encoding)
本節介紹一種基于字節對編碼(BPE)概念更復雜的分詞方案,BPE分詞器曾用于訓練大語言模型,如GPT -2,GPT -3以及最初用于ChatGPT 的LLM。
由于從零開始實現BPE可能相對復雜,我們將使用一個名為tiktoken的現有Python開源庫鏈接,該庫基于Rust中的源代碼非常高效地實現了BPE算法。與其他Python庫類似,我們可以通過Python的pip安裝程序從終端安裝tiktoken庫:
pip install tiktoken
import importlib
import tiktokenprint("tiktoken version:", importlib.metadata.version("tiktoken"))
查看對應的版本:
tiktoken version: 0.9.0
安裝完成后,可以按照如下方式通過tiktoken實例化BPE分詞器:
tokenizer = tiktoken.get_encoding("gpt2")
這個分詞器用法類似于之前實現的SimpleTokenizerV2,都是通過 encode 方法使用:
text = ("Hello, do you like tea? <|endoftext|> In the sunlit terraces""of someunknownPlace."
)integers = tokenizer.encode(text, allowed_special={"<|endoftext|>"})print(integers)
上述代碼輸出token ID列表:
[15496, 11, 466, 345, 588, 8887, 30, 220, 50256, 554, 262, 4252, 18250, 8812, 2114, 1659, 617, 34680, 27271, 13]
可以使用 decode 方法將token ID 列表轉換回文本,類似于我們之前實現的 SimpleTokenizerV2 類的 decode 方法:
strings = tokenizer.decode(integers)print(strings)
輸出:
Hello, do you like tea? <|endoftext|> In the sunlit terracesof someunknownPlace.
根據上面的token ID 和解碼后的文本,我們可以觀察到兩點:首先,<|endoftext|> token被分配了一個相對較大的token ID,即 50256。實際上,用于訓練諸如 GPT-2、GPT-3 以及最初用于訓練 ChatGPT 的模型的 BPE 分詞器,總詞匯表大小為 50,257,其中 <|endoftext|> 被分配了最大的token ID。
其次,上述BPE分詞器能夠正確編碼和解碼未知詞匯,例如“someunknownPlace”。BPE分詞器可以處理任何未知詞匯。它是如何在不使用 <|unk|> token的情況下實現這一點的?
BPE背后的算法將不在其預定義詞匯表中的單詞分解為更小的子詞單元甚至單個字符,使其能夠處理超出詞匯表的單詞。因此,得益于BPE算法,如果分詞器在分詞過程中遇到一個不熟悉的單詞,它可以將其表示為一系列子詞token或字符,如圖2.11所示。
如圖 2.11 所示,將未知單詞分解為單個字符的能力確保了分詞器以及隨之訓練的 LLM 能夠處理任何文本,即使文本中包含訓練數據中不存在的單詞。
Note
未知次的字節對編碼
text_home_work = ("Akwirw ier")
intergers_home_work = tokenizer.encode(text_home_work,allowed_special={"<|endoftext|>"})print(intergers_home_work)
輸出的結果和圖2.11完全一致:
[33901, 86, 343, 86, 220, 959]
decode得到原始輸出:
strings_home_work = tokenizer.decode(intergers_home_work)print(strings_home_work)
BPE通過反復合并頻繁出現的字符和子詞來構建詞匯表。例如,BPE 首先將所有單個字符(“a”,“b”,等)添加到詞匯表中。在下一階段,它將經常一起出現的字符組合合并為子詞。例如,“d”和“e”可能會合并成子詞“de”,這個組合在許多英語單詞中很常見,如“define”、“depend”、“made”和“hidden”。這些合并是通過頻率截止值來確定的。
Tip
個人思考: 字節對編碼是一種基于統計的方法,它會先從整個語料庫中找出最常見的字節對(byte pair),然后把這些字節對合并成一個新的單元。讓我們用一個具體的示例來描述這個過程:
假如有句子:“The cat drank the milk because it was hungry”
1.初始化:BPE會先將句子中每個字符視為一個單獨的token
['T', 'h', 'e', ' ', 'c', 'a', 't', ' ', 'd', 'r', 'a', 'n', 'k', ' ', 't', 'h', 'e', ' ', 'm', 'i', 'l', 'k', ' ', 'b', 'e', 'c', 'a', 'u', 's', 'e', ' ', 'i', 't', ' ', 'w', 'a', 's', ' ', 'h', 'u', 'n', 'g', 'r', 'y']
2. 統計最常見的字節對
BPE算法會在這些token中找到出現頻率最高的“字節對”(即相鄰的兩個字符),然后將其合并為一個新的token。
例如這里最常見的字節對時(‘t’, ‘h’),因為它在單詞"the"和"that"中出現頻率較高。
3.合并字節對
根據統計結果,我們將最常見的字節對(‘t’, ‘h’)合并為一個新的token,其它類似
['Th', 'e', ' ', 'c', 'a', 't', ' ', 'dr', 'a', 'nk', ' ', 'th', 'e', ' ', 'm', 'i', 'l', 'k', ' ', 'be', 'c', 'a', 'u', 'se', ' ', 'it', ' ', 'wa', 's', ' ', 'hu', 'n', 'gr', 'y']
4.重復步驟2和3,得到最終的token序列
['The', ' ', 'cat', ' ', 'drank', ' ', 'the', ' ', 'milk', ' ', 'because', ' ', 'it', ' ', 'was', ' ', 'hungry']
2.6 使用滑動窗口進行數據采樣
上一節詳細介紹了分詞步驟以及將字符串分詞成token再轉換為整數token ID的過程。在最終為LLM創建嵌入之前,需要提前做就是生成訓練LLM所需的輸入-目標對。
這些輸入-目標對是什么樣的?正如我們在第一章中所說,LLM通過預測文本中的下一個單詞進行預訓練,如圖2.12所示。
在本節,將實現一個數據加載器,通過滑動窗口的方法從訓練數據集中提取圖2.12所示的輸入-目標對。
首先,我們將使用前一節中介紹的BPE分詞器對我們之前處理短篇小說進行分詞:
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
5145:表示在訓練集上應用BPE分詞器后,返回的token總數。
接下來,我們從數據集中移除前50個token以便演示,因為這會在接下來的步驟中產生稍微更有趣的文本段落。
enc_sample = enc_text[50:]
創建輸入-目標對以進行下一個單詞預測任務的最簡單和最直觀的方法之一是創建兩個變量x和y,其中x包含輸入token,y包含目標,即輸入向右移動1位的結果。
context_size = 4x = 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 ----> establishedand established ----> himselfand established himself ----> inand established himself in ----> a
現在已經創建了輸入-目標對,可以在接下來的章節中應用于 LLM 的訓練。
在我們將token轉換為嵌入之前,還有一個任務要完成,正如我們在本章開始時提到的:實現一個高效的數據加載器,該加載器遍歷輸入數據集并將輸入和目標作為 PyTorch 張量返回,這些張量可以視為多維數組。
具體來說,我們的目標是返回兩個張量:一個輸入張量,包括 LLM 看到的文本,另一個目標張量,包含 LLM 需要預測的目標,如圖 2.13 所示。
雖然圖2.13展示了字符串格式的token以供說明,但代碼實現將直接操作token ID,因為 BPE 分詞器的 encode 方法將分詞和轉換為token ID 的過程合并為了一個步驟。
為了實現高效的數據加載器,我們將使用 PyTorch 內置的 Dataset 和 DataLoader 類。
數據加載器的實現細節:
# Listing 2.5 A dataset for batched inputs and targets
import torch
from torch.utils.data import Dataset, DataLoaderclass GPTDatasetV1(Dataset):def __init__(self, txt, tokenizer, max_length, stride):self.input_ids = []self.target_ids = []token_ids = tokenizer.encode(txt) #Afor i in range(0, len(token_ids) - max_length, stride): #Binput_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): #Creturn len(self.input_ids)def __getitem__(self, idx): #Dreturn self.input_ids[idx], self.target_ids[idx]#A 將整個文本進行分詞
#B 使用滑動窗口將書籍分塊為最大長度的重疊序列。
#C 返回數據集的總行數
#D 從數據集中返回指定行
GPTDatasetV1 類繼承自 PyTorch Dataset 類,定義了如何從數據集中提取單行,其中每行由多個token ID(基于 max_length)組成,并賦值給 input_chunk 張量。target_chunk 張量則包含相應的目標。
使用剛創建的 GPTDatasetV1 類,通過 PyTorch DataLoader 以批量方式加載輸入:
# Listing 2.6 A data loader to generate batches with input-with pairs
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") #Adataset = GPTDatasetV1(txt, tokenizer, max_length, stride) #Bdataloader = DataLoader(dataset,batch_size=batch_size,shuffle=shuffle,drop_last=drop_last, #Cnum_workers=0 #D)return dataloader#A 初始化分詞器
#B 創建GPTDatasetV1類
#C drop_last=True會在最后一批次小于指定的batch_size時丟棄該批次,以防止訓練期間的損失峰值
#D 用于預處理的CPU進程數量
讓我們設置 batch_size = 1 和 max_length = 4,觀察代碼中的 GPTDatasetV1 類和 create_dataloader_v1 函數如何協同工作:
with open("the-verdict.txt", "r", encoding="utf-8") as f:raw_text = f.read()dataloader = create_dataloader_v1(raw_text, batch_size=1, max_length=4, stride=1, shuffle=False)
data_iter = iter(dataloader) #A
first_batch = next(data_iter)
print(first_batch)#A 將數據加載器轉換為 Python 迭代器,以便通過 Python 的內置 next() 函數獲取下一個數據條目。
輸出結果:
[tensor([[ 40, 367, 2885, 1464]]), tensor([[ 367, 2885, 1464, 1807]])]
first_batch 變量包含兩個張量:第一個張量存儲輸入token ID,第二個張量存儲目標token ID。由于 max_length 設置為 4,因此這兩個張量各包含 4 個token ID。請注意,輸入大小為 4 相對較小。通常,訓練 LLM 的輸入大小至少為 256。
為了闡明 stride=1 的含義,讓我們從這個數據集中提取另一個批次:
second_batch = next(data_iter)
print(second_batch)
第二批次的具體內容:
[tensor([[ 367, 2885, 1464, 1807]]), tensor([[2885, 1464, 1807, 3619]])]
如果我們將第一個批次與第二個批次進行比較,可以看到第二個批次的token ID 相較于第一個批次右移了一個位置(例如,第一個批次輸入中的第二個 ID 是 367,而它是第二個批次輸入的第一個 ID)。步幅設置決定了輸入在批次之間移動的位置數,模擬了滑動窗口的方法,如圖 2.14 所示。
Note
針對數據加載器(Data Loaders)設置不同步幅和上下文大小
例如 max_length=2 和 stride=2,以及 max_length=8 和 stride=2。
1?? max_length=2 和 stride=2
dataloader_2_2 = create_dataloader_v1(raw_text, batch_size=1, max_length=2, stride=2, shuffle=False
)data_iter_2_2 = iter(dataloader_2_2)
first_batch_2_2= next(data_iter_2_2)
print(first_batch_2_2)
print('-'*20)second_batch_2_2 = next(data_iter_2_2)
print(second_batch_2_2)
輸出結果:
[tensor([[ 40, 367]]), tensor([[ 367, 2885]])]
--------------------
[tensor([[2885, 1464]]), tensor([[1464, 1807]])]
2?? max_length=8 和 stride=2
dataloader_8_2 = create_dataloader_v1(raw_text, batch_size=1, max_length=8, stride=2, shuffle=False
)data_iter_8_2 = iter(dataloader_8_2)
first_batch_8_2= next(data_iter_8_2)
print(first_batch_8_2)
print('-'*50)second_batch_8_2 = next(data_iter_8_2)
print(second_batch_8_2)
輸出:
[tensor([[ 40, 367, 2885, 1464, 1807, 3619, 402, 271]]), tensor([[ 367, 2885, 1464, 1807, 3619, 402, 271, 10899]])]
--------------------------------------------------
[tensor([[ 2885, 1464, 1807, 3619, 402, 271, 10899, 2138]]), tensor([[ 1464, 1807, 3619, 402, 271, 10899, 2138, 257]])]
我們從數據加載器中采樣的批次大小都為1,小批次大小在訓練時消耗內存較少,但會導致模型更新變得更加困難。就像在常規深度學習中一樣,批次大小的設置是一個權衡,它作為超參數需要在訓練 LLM 過程中進行實驗和調整。
現在先簡要了解一下如何使用數據加載器以大于 1 的批次大小進行采樣:
dataloader = create_dataloader_v1(raw_text, batch_size=8, max_length=4, stride=4, shuffle=False)data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print("Inputs:\n", inputs)
print("\nTargets:\n", targets)
Inputs:tensor([[ 40, 367, 2885, 1464],[ 1807, 3619, 402, 271],[10899, 2138, 257, 7026],[15632, 438, 2016, 257],[ 922, 5891, 1576, 438],[ 568, 340, 373, 645],[ 1049, 5975, 284, 502],[ 284, 3285, 326, 11]])Targets:tensor([[ 367, 2885, 1464, 1807],[ 3619, 402, 271, 10899],[ 2138, 257, 7026, 15632],[ 438, 2016, 257, 922],[ 5891, 1576, 438, 568],[ 340, 373, 645, 1049],[ 5975, 284, 502, 284],[ 3285, 326, 11, 287]])
2.7 構建詞嵌入層
為 LLM 準備訓練集的最后一步是將token ID 轉換為嵌入向量,如圖 2.15 所示
還需注意的是,我們首先會以隨機值初始化這些嵌入權重。這一初始化為 LLM 的學習過程提供了起始點。
對于GPT類大語言模型(LLM)來說,連續向量表示(Embedding)非常重要,原因在于這些模型使用深度神經網絡結構,并通過反向傳播算法(backpropagation)進行訓練。
Tip
個人思考: 為什么通過反向傳播算法訓練的大語言模型必須具有Embedding,讓我們通過以下幾個方面來分析和思考:
1.深度神經網絡和連續向量表示GPT 類模型(以及其他深度神經網絡)是基于大量的矩陣運算和數值計算構建的,尤其是神經元之間的連接權重和偏置在訓練過程中不斷更新。這些運算要求輸入的數據是數值形式的向量,因為神經網絡只能對數值數據進行有效計算,而無法直接處理原始的離散文字數據(如單詞、句子)。
- 向量表示:通過將每個單詞、句子或段落轉換為連續向量(Embedding),可以在高維空間中表示文本的語義關系。例如,通過詞嵌入(如 Word2Vec、GloVe)或上下文嵌入(如 GPT 中的詞嵌入層),每個單詞都被轉換為一個向量,這個向量可以用于神經網絡的計算。
2.向量嵌入的作用
連續向量表示不僅讓文本數據可以進入神經網絡,還幫助模型捕捉和表示文本之間的語義關系。例如:
- 同義詞或相似詞:在向量空間中,相似的單詞可以有接近的向量表示。這種語義相似性幫助模型理解上下文,并在生成文本時提供參考。
- 上下文關系:GPT 等 LLM 模型不僅依賴單詞級別的向量表示,還會考慮句子或段落上下文,形成動態嵌入,從而生成更具連貫性的文本。
3.反向傳播算法的要求
深度神經網絡通過反向傳播算法進行訓練,反向傳播的本質是利用梯度下降法來更新網絡的權重,以最小化損失函數(loss function)。反向傳播要求每一層的輸入、輸出和權重都能夠參與梯度計算,而梯度計算只能應用于數值數據。
- 自動微分與梯度計算:在反向傳播中,神經網絡會根據損失函數的導數來計算梯度,這個過程依賴于自動微分(automatic differentiation)。為了計算每層的梯度,輸入的數據必須是數值形式(即向量),否則無法對離散的文本數據求導。
- 梯度更新權重:每次更新網絡權重時,神經網絡會根據每一層的輸入和輸出來調整權重,以更好地學習數據的模式。如果輸入不是數值形式,就無法實現梯度更新,從而無法通過反向傳播訓練網絡。
通過一個實際示例來說明token ID 到嵌入向量轉換的工作原理。假設我們有以下四個輸入token,它們的 ID 分別為 2、3、5 和 1:
input_ids = torch.tensor([2, 3, 5, 1])
假設我們有一個只有 6 個單詞的小詞匯表(而不是 BPE 分詞器中的 50,257 個單詞),并且我們希望創建大小為 3 的嵌入向量(在 GPT-3 中,嵌入大小為 12,288 維):
vocab_size = 6
output_dim = 3torch.manual_seed(123)
embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
print(embedding_layer.weight)
我們可以使用 vocab_size 和 output_dim在 PyTorch 中實例化一個嵌入層,并將隨機種子設置為 123,以便結果可重復。
輸出嵌入層的基礎權重矩陣:
Parameter containing:
tensor([[ 0.3374, -0.1778, -0.1690],[ 0.9178, 1.5810, 1.3010],[ 1.2753, -0.2010, -0.1606],[-0.4015, 0.9666, -1.1481],[-1.1589, 0.3255, -0.6315],[-2.8400, -0.7849, -1.4096]], requires_grad=True)
嵌入層的權重矩陣由比較小的隨機值組成。這些值將在LLM訓練過程中作為LLM優化的一部分被優化。此外,權重矩陣有六行三列。嵌入矩陣的每一行代表詞匯表中的一個token(每個token都有一個唯一的向量表示),而每一列代表嵌入空間中的一個維度(在這個例子中,嵌入維度為3,即每個token被表示為一個3維向量)。
實例化好嵌入層后,我們可以通過它獲取指定token ID的嵌入向量:
print(embedding_layer(torch.tensor([3])))
結果:
tensor([[-0.4015, 0.9666, -1.1481]], grad_fn=<EmbeddingBackward0>)
將token ID 3 的嵌入向量與之前的嵌入矩陣進行比較,會發現它與第四行相同(Python 從零開始索引,因此它對應于索引 3 的行)。換句話說,嵌入層本質上是一個查找功能,通過token ID 從嵌入層的權重矩陣中檢索行。
我們已經看到如何將單個token ID 轉換為三維嵌入向量。現在讓我們將其應用于之前定義的所有四個輸入 ID(torch.tensor([2, 3, 5, 1])):
print(embedding_layer(input_ids))
輸出:
tensor([[ 1.2753, -0.2010, -0.1606],[-0.4015, 0.9666, -1.1481],[-2.8400, -0.7849, -1.4096],[ 0.9178, 1.5810, 1.3010]], grad_fn=<EmbeddingBackward0>)
輸出矩陣中的每一行都是通過從嵌入權重矩陣進行查找操作獲得的,如圖。
2.8 位置編碼
上一節將token ID 轉換為連續的向量表示,即所謂的token嵌入。原則上,這適合作為 LLM 的輸入。然而,LLM的一個小缺點是它們的自注意力機制——對序列中token的位置或順序沒有概念。
之前引入的嵌入層的工作方式是,無論token ID 在輸入序列中的位置如何,相同的token ID 始終映射到相同的向量表示,如圖 2.17 所示。
從原則上講,確定性的、與位置無關的token ID 嵌入對于可重復性是有益的。然而,由于LLM的自注意力機制本身也是與位置無關的,因此向LLM注入額外的位置信息是有幫助的。
絕對位置嵌入與序列中的特定位置直接相關。對于輸入序列中的每個位置,都會將一個唯一的絕對位置嵌入向量添加到token的嵌入向量中,以傳達其確切位置。例如,第一個token將具有特定的位置嵌入,第二個token將具有另一個不同的嵌入,依此類推,如圖2.18所示。
與關注token在序列中的絕對位置不同,相對位置嵌入強調的是token之間的相對位置或距離。這意味著模型學習的是“相隔多遠”的關系,而不是“在什么確切位置”。這樣的優勢在于,即使模型在訓練時沒有接觸過不同的長度,它也可以更好地適應各種長度的序列。
這兩種類型的位置嵌入旨在增強 LLM 理解token順序與關系的能力,從而確保在預測時能對上下文具有更準確的感知。選擇哪種類型的位置嵌入通常取決于特定的應用和所處理數據的性質。
OpenAI 的 GPT 模型使用絕對位置嵌入,這些嵌入在訓練過程中進行優化,而不是像原始 Transformer 模型中的位置編碼那樣是固定或預定義的。
之前,我們在本章中專注于非常小的嵌入大小以便于說明。我們現在考慮更現實和有用的嵌入大小,并將輸入token編碼為256維的向量表示。這比原始的GPT-3模型使用的要小(在GPT-3中,嵌入大小為12,288維),但對于實驗仍然是合理的。此外,我們假設token ID 是由我們之前實現的BPE分詞器創建的,該分詞器的詞匯量為50,257:
vocab_size = 50257
output_dim = 256token_embedding_layer = torch.nn.Embedding(vocab_size, output_dim)
使用上面的 token_embedding_layer,如果我們從數據加載器中采樣數據,我們將每個批次中的每個token嵌入到一個 256 維的向量中。如果我們的批次大小為 8,每個批次有四個token,那么結果將是一個形狀為 8 x 4 x 256 的張量。
首先,讓我們實例化 2.6 節中創建的數據加載器,使用滑動窗口進行數據采樣:
max_length = 4
dataloader = create_dataloader_v1(raw_text, batch_size=8, max_length=max_length,stride=max_length, shuffle=False
)
data_iter = iter(dataloader)
inputs, targets = next(data_iter)
print("Token IDs:\n", inputs)
print("\nInputs shape:\n", inputs.shape)
輸出結果:
Token IDs:tensor([[ 40, 367, 2885, 1464],[ 1807, 3619, 402, 271],[10899, 2138, 257, 7026],[15632, 438, 2016, 257],[ 922, 5891, 1576, 438],[ 568, 340, 373, 645],[ 1049, 5975, 284, 502],[ 284, 3285, 326, 11]])Inputs shape:torch.Size([8, 4])
我們可以看到,tokenID張量是8x4維的,這意味著數據批次由8個文本樣本組成,每個樣本有4個token。
現在,讓我們使用嵌入層將這些token ID 轉換為 256 維的向量:
token_embeddings = token_embedding_layer(inputs)
print(token_embeddings.shape)
輸出:
torch.Size([8, 4, 256])
從 8x4x256 維的張量輸出中,我們可以看到,每個token ID 現在被嵌入為一個 256 維的向量。
對于 GPT 模型所使用的絕對嵌入方法,我們只需創建另一個嵌入層,其維度與 token_embedding_layer 的維度相同
context_length = max_length
pos_embedding_layer = torch.nn.Embedding(context_length, output_dim)
pos_embeddings = pos_embedding_layer(torch.arange(context_length))
print(pos_embeddings.shape)
輸出結果:
torch.Size([4, 256])
正如我們所見,位置嵌入張量由四個 256 維向量組成。我們現在可以將這些直接添加到token嵌入中,在每個批次中,PyTorch 會將 4x256 維的 pos_embeddings 張量添加到每個 4x256 維的token嵌入張量中:
token_embeddings = token_embedding_layer(inputs)
print(token_embeddings.shape)
輸出:
torch.Size([8, 4, 256])
我們創建的 input_embeddings,如圖 2.19 所示,現在可作為LLM的核心模塊的輸入嵌入。
2.9 本章摘要
- LLM 需要將文本數據轉換為數值向量,這稱之為嵌入,因為它們無法處理原始文本。嵌入將離散數據(如單詞或圖像)轉化為連續的向量空間,從而使其能夠與神經網絡操作兼容。
- 作為第一步,原始文本被分解為token,這些token可以是單詞或字符。然后,這些token被轉換為整數表示,稱為token ID。
- 可以添加特殊token,如 <|unk|> 和 <|endoftext|>,以增強模型的理解能力,并處理各種上下文,例如未知單詞或無關文本之間的邊界分隔。
- 用于像 GPT-2 和 GPT-3 這樣的 LLM 的字節對編碼(BPE)分詞器,可以通過將未知單詞分解為子詞單元或單個字符,高效地處理這些單詞。
- 我們在分詞后的文本數據上采用滑動窗口方法,以生成用于 LLM 訓練的輸入-目標對。
- 在 PyTorch 中,嵌入層作為一種查找操作,用于檢索與token ID 對應的向量。生成的嵌入向量提供了token的連續表示,這在訓練像 LLM 這樣的深度學習模型時至關重要。
- 雖然token嵌入為每個token提供了一致的向量表示,但它們并沒有考慮token在序列中的位置。為了解決這個問題,存在兩種主要類型的位置嵌入:絕對位置嵌入和相對位置嵌入。OpenAI 的 GPT 模型采用絕對位置嵌入,這些位置嵌入向量會與token嵌入向量相加,并在模型訓練過程中進行優化。