
API 設計原理:從理論到實踐
我很高興地宣布我的新書《從頭構建大型語言模型》即將出版,由 Manning 出版。這本書經過近兩年的制作,現在終于在 Manning 網站上以電子書和印刷版的形式提供(也可以在亞馬遜上預訂)。
根據我的經驗,深入理解一個概念的最好方法是從頭開始構建它。這本書將指導您完成構建類似 GPT 的 LLM 的整個過程——從實現數據輸入到使用指令數據進行微調。我的目標是,讓您在讀完這本書后,能夠深入、詳細且透徹地了解LLM的工作原理。
為了慶祝本書的出版,我特地分享了一章摘錄,指導您如何將預訓練的LLM微調為垃圾郵件分類器。
重要提示
關于分類微調的章節長達 35 頁——對于一篇文章來說太長了。因此,在這篇文章中,我將重點介紹約 10 頁的子集,介紹分類微調背后的背景和核心概念。
此外,我還將分享一些書中未涵蓋的額外實驗見解,并解答讀者可能遇到的常見問題。(請注意,以下摘錄基于 Manning 的專業文本編輯和最終圖形設計之前的個人草稿。)
此外,我還將回答您可能遇到的有關訓練 LLM 分類器的 7 個問題:
1)我們需要訓練所有層嗎?
2)為什么要微調最后一個標記,而不是第一個標記?
3)BERT 與 GPT 性能相比如何?
4)我們是否應該禁用因果掩碼?
5)增加模型尺寸會產生什么影響?
6)我們可以期待 LoRA 有哪些改進?
7) 有填充還是無填充?
祝您閱讀愉快!
微調語言模型最常見的方法是指令微調和分類微調。指令微調涉及使用特定指令在一組任務上訓練語言模型,以提高其理解和執行自然??語言提示中描述的任務的能力,如下圖 1 所示。
下一章將討論指令微調,如上圖 1 所示。同時,本章將著重介紹分類微調,如果您具備機器學習背景,那么您可能對這個概念已經有所了解。
在分類微調中,模型經過訓練可以識別一組特定的類別標簽,例如“垃圾郵件”和“非垃圾郵件”。分類任務的例子不僅僅局限于大型語言模型和電子郵件過濾,它們還廣泛存在于從圖像中識別不同種類的植物、將新聞文章分類為體育、政治或科技等主題,以及判斷醫學影像中的腫瘤是良性還是惡性等場景中。
關鍵點在于,分類微調模型僅限于預測它在訓練期間遇到的類別 – 例如,它可以確定某些內容是“垃圾郵件”還是“非垃圾郵件”,如下圖 2 所示,但它無法對輸入文本說出任何其他信息。
與圖 2 所示的分類微調模型相比,指令微調模型通常能夠執行更廣泛的任務。我們可以將經過分類微調的模型看作是高度專業化的模型,而且通常來說,開發這樣的專業化模型要比開發能夠適用于各種任務的通用模型來得更容易。
選擇正確的方法
指令微調可提高模型理解和根據特定用戶指令生成響應的能力。指令微調最適合需要根據復雜用戶指令處理各種任務的模型,可提高靈活性和交互質量。另一方面,分類微調非常適合需要將數據精確分類為預定義類別的項目,例如情緒分析或垃圾郵件檢測。
雖然指令微調具有更廣泛的用途,但要開發出一個能夠勝任多種任務的模型,它需要更大的數據集以及更多的計算資源。相比之下,分類微調需要的數據和計算能力較少,但其用途僅限于模型訓練的特定類別。
由于這是摘錄的內容,我們將直接跳過數據準備和模型初始化的部分,這些內容已經在前面的章節中實現并完成了預訓練。根據我的經驗,與閱讀實體書相比,閱讀冗長的數字文章時保持注意力可能更具挑戰性。因此,我將嘗試讓此摘錄/文章緊緊圍繞本章的一個關鍵要點。
為了提供一些關于本章摘錄重點介紹的部分背景信息,本摘錄主要關注將一般預訓練的 LLM 轉換為用于分類任務的專門的 LLM 所需的修改,如下圖 3 所示。
但是,在我們跳轉到圖3中提到的對LLM的修改之前,讓我們先簡要地了解一下我們正在使用的預訓練LLM。
因此,為了簡單起見,假設我們設置代碼來加載模型如下:
model = GPTModel(BASE_CONFIG)
load_weights_into_gpt(model, params)
model.eval()
在將模型權重加載到GPTModel之后,我們利用前幾章中定義的文本生成實用函數,以確保模型能夠生成連貫的文本。
from chapter04 import generate_text_simple
from chapter05 import text_to_token_ids, token_ids_to_text
text_1 = "Every effort moves you"
token_ids = generate_text_simple(
model=model,
idx=text_to_token_ids(text_1, tokenizer),
max_new_tokens=15,
context_size=BASE_CONFIG["context_length"]
)
print(token_ids_to_text(token_ids, tokenizer))
根據以下輸出我們可以看出,模型生成了連貫的文本,這表明模型權重已正確加載:
Every effort moves you forward.
The first step is to understand the importance of your work
現在,在我們開始將模型微調為垃圾郵件分類器之前,讓我們先看看該模型是否已經可以通過輸入指令來對垃圾郵件進行分類:
text_2 = (
"Is the following text 'spam'? Answer with 'yes' or 'no':"
" 'You are a winner you have been specially"
" selected to receive $1000 cash or a $2000 award.'"
)
token_ids = generate_text_simple(
model=model,
idx=text_to_token_ids(text_2, tokenizer),
max_new_tokens=23,
context_size=BASE_CONFIG["context_length"]
)
print(token_ids_to_text(token_ids, tokenizer))
模型輸出如下:
Is the following text 'spam'? Answer with 'yes' or 'no': 'You are a winner you have been specially selected to receive $1000 cash or a $2000 award.'
The following text 'spam'? Answer with 'yes' or 'no': 'You are a winner
根據輸出結果,很明顯模型在遵循指令方面遇到了困難。
這是在意料之中的,因為它目前僅經過了預訓練,還未經過指令微調。我們將在接下來的章節中深入探討這個問題。
下一節為分類微調準備模型。
在本節中,我們將修改預訓練的大型語言模型,以準備進行分類微調。為此,我們將原始輸出層(將隱藏表示映射到 50,257 個唯一標記的詞匯表)替換為較小的輸出層(映射到兩個類別:0(“非垃圾郵件”)和 1(“垃圾郵件”),如下圖 4 所示。
如上圖4所示,除了替換輸出層外,我們使用與前幾章相同的模型。
輸出層節點
從技術上講,我們可以使用單個輸出節點,因為我們正在處理二元分類任務。但是,這需要修改損失函數,如附錄 B 參考部分中的一篇文章所述。因此,我們選擇了一種更通用的方法,其中輸出節點的數量與類的數量相匹配。例如,在處理有三類分類問題(如將新聞文章分類為“技術”、“體育”或“政治”)時,我們會使用三個輸出節點,以此類推。
在嘗試進行圖4中所示的修改之前,我們先通過打印模型架構(print(model)
)來查看其結構,輸出結果將如下所示:
GPTModel(
(tok_emb): Embedding(50257, 768)
(pos_emb): Embedding(1024, 768)
(drop_emb): Dropout(p=0.0, inplace=False)
(trf_blocks): Sequential(
...
(11): TransformerBlock(
(att): MultiHeadAttention(
(W_query): Linear(in_features=768, out_features=768, bias=True)
(W_key): Linear(in_features=768, out_features=768, bias=True)
(W_value): Linear(in_features=768, out_features=768, bias=True)
(out_proj): Linear(in_features=768, out_features=768, bias=True)
(dropout): Dropout(p=0.0, inplace=False)
)
(ff): FeedForward(
(layers): Sequential(
(0): Linear(in_features=768, out_features=3072, bias=True)
(1): GELU()
(2): Linear(in_features=3072, out_features=768, bias=True)
)
)
(norm1): LayerNorm()
(norm2): LayerNorm()
(drop_resid): Dropout(p=0.0, inplace=False)
)
)
(final_norm): LayerNorm()
(out_head): Linear(in_features=768, out_features=50257, bias=False)
)
上面,我們可以看到第 4 章中實現的架構整齊地布局。如第 4 章所述,GPTModel
由嵌入層、隨后的 12 個相同的變壓器塊(為簡潔起見,僅顯示最后一個塊)、最后層LayerNorm
和輸出層out_head
組成。
接下來,我們用一個新的輸出層替換out_head
,如圖 4 所示,我們會??對其進行微調。
對選定層進行微調與對所有層進行微調
由于我們從預訓練模型開始,因此無需對所有模型層進行微調。這是因為在基于神經網絡的語言模型中,較低層通常捕獲適用于廣泛任務和數據集的基本語言結構和語義。因此,僅對最后的層(靠近輸出的層)進行微調通常足以使模型適應新任務,這些層更特定于細微的語言模式和特定于任務的特征。一個額外的優點是,僅對少數層進行微調在計算上更為高效。對于對此感興趣的讀者,可以在附錄B的本章參考資料部分找到更多關于選擇哪些層進行微調的信息(包括相關實驗)。
為了讓模型做好分類微調的準備,我們首先需要凍結模型,即設置所有層為不可訓練狀態。
for param in model.parameters():
param.requires_grad = False
然后,如前面的圖 4 所示,我們替換輸出層(model.out_head
),該層原本將層輸入映射到 50,257 維(詞匯表的大小):
torch.manual_seed(123)
num_classes = 2
model.out_head = torch.nn.Linear(
in_features=BASE_CONFIG["emb_dim"],
out_features=num_classes
)
請注意,在上面的代碼中,我們使用了BASE_CONFIG["emb_dim"]
,這個值在“gpt2-small”(124M參數版本)模型中等于768,這樣做是為了使下面的代碼更加具有通用性。這意味著我們也可以使用相同的代碼來處理更大的 GPT-2 模型變體。
這個新的model.out_head
輸出層的requires_grad
屬性設置True
為默認,這意味著它是模型中唯一在訓練期間會更新的層。
從技術上講,訓練我們剛剛添加的輸出層就足夠了。但是,正如我在實驗中發現的那樣,微調其他層可以顯著提高微調模型的預測性能。
此外,我們將最后一個變壓器塊以及連接該塊到輸出層的LayerNorm配置為可訓練的,如圖5所示。
為了使最終的LayerNorm
和最后一個 Transformer 塊可訓練,如上圖 5 所示,我們將它們各自設置為True
:
for param in model.trf_blocks[-1].parameters():
param.requires_grad = True
for param in model.final_norm.parameters():
param.requires_grad = True
即使我們添加了新的輸出層,并將部分層設置為可訓練或不可訓練,我們仍然可以按照前幾章的方法使用該模型。例如,我們可以向模型輸入與前幾章相同的示例文本。以下是一段示例文本:
inputs = tokenizer.encode("Do you have time")
inputs = torch.tensor(inputs).unsqueeze(0)
print("Inputs:", inputs)
print("Inputs dimensions:", inputs.shape)
如打印輸出所示,上面的代碼將輸入編碼為由 4 個輸入標記組成的張量:
Inputs: tensor([[5211, 345, 423, 640]])
Inputs dimensions: torch.Size([1, 4])
然后,我們可以像往常一樣將編碼的令牌 ID 傳遞給模型:
with torch.no_grad():
outputs = model(inputs)
print("Outputs:\n", outputs)
print("Outputs dimensions:", outputs.shape)
輸出張量如下所示:
Outputs:
tensor([[[-1.5854, 0.9904],
[-3.7235, 7.4548],
[-2.2661, 6.6049],
[-3.5983, 3.9902]]])
Outputs dimensions: torch.Size([1, 4, 2])
在第 4 章和第 5 章中,類似的輸入會產生一個形狀為 的輸出張量[1, 4, 50257]
,其中 50,257 代表詞匯量。與前幾章相同,輸出的行數仍然與輸入標記的數量相對應(在本例中為4)。但是,由于我們已經替換了模型的輸出層,因此每個輸出的嵌入維度(即列數)現在已經減少到了2,而不是之前的50,257。
請記住,我們的目標是微調這個模型,讓它能夠返回一個類別標簽,這個標簽能夠指示模型的輸入是垃圾郵件還是非垃圾郵件。為此,我們不需要微調所有 4 個輸出行,而是可以專注于單個輸出標記。具體來說,我們將重點關注與最后一個輸出標記相對應的最后一行,如下圖 6 所示。
為了從輸出張量中提取最后一個輸出標記(如上圖 6 所示),我們使用以下代碼:
print("Last output token:", outputs[:, -1, :])
這將打印以下內容:
Last output token: tensor([[-3.5983, 3.9902]])
在進入下一節之前,讓我們回顧一下我們的討論。我們將著重講解如何將輸出值轉換為類別標簽預測。但在那之前,讓我們先了解一下為何我們特別關注最后一個輸出標記,而不是第一個、第二個或第三個。
在第 3 章中,我們探討了注意力機制,該機制在每個輸入標記與每個其他輸入標記之間建立了關系。隨后,我們引入了因果注意力掩碼的概念,該概念常用于類似 GPT 的模型。此掩碼將標記的焦點限制在其當前位置及其之前的位置,確保每個標記只能受到其自身和前面標記的影響,如下圖 7 所示。
鑒于圖7中所示的因果注意掩碼設置,序列中的最后一個標記累積了最多的信息,因為它能夠訪問到所有先前標記的數據,是唯一一個擁有這種能力的標記。因此,在我們的垃圾郵件分類任務中,我們在微調過程中將重點放在最后一個標記上。
修改完模型后,下一節將詳細介紹將最后一個標記轉換為類標簽預測的過程,并計算模型的初始預測準確率。接下來,我們將在下一節中針對垃圾郵件分類任務微調模型。
由于這段摘錄已經相當冗長,我將不再詳細闡述模型評估的內容。但是至少我想與您分享一張圖表,該圖表展示了訓練期間訓練集和驗證集上的分類準確率,以此來證明該模型確實學習得很好。
如上圖 8 所示,該模型的驗證準確率約為 97%。測試準確率(未顯示)約為 96%。此外,我們可以看到模型略微過擬合,這從訓練集準確率略高可以看出。總體來說,該模型的表現非常出色:在測試集上達到了96%的準確率,這意味著它能夠正確識別出100條消息中的96條是垃圾郵件還是非垃圾郵件。(我們在此摘錄中沒有討論數據集,但這是一個平衡的數據集,其中 50% 是垃圾郵件,50% 是非垃圾郵件,這意味著隨機或訓練不良的分類器將實現大約 50% 的分類準確率。)
到這個階段,您可能對某些設計選擇存在諸多疑問,因此我想分享一些其他實驗的結果,這些結果或許能幫助您解答一個或多個疑問或消除某些疑慮。
免責聲明:實驗大多僅在 1 個數據集上運行,并且將來應該在其他數據集上重復運行,以測試這些發現是否具有普遍性。
在上面的章節摘錄中,出于效率原因,我們僅訓練了輸出層和最后一個變壓器塊。如前所述,對于分類微調,沒有必要更新 LLM 中的所有層。(我們更新的權重越少,訓練速度就越快,因為我們不需要在反向傳播期間計算這些權重的梯度。)
但是,您可能好奇如果不更新所有層,我們在預測性能上會有所損失。因此,在下表中,我對比了微調所有層、僅微調最后一個轉換器塊(加上最后一層)以及僅微調最后一層的實驗結果。
如上表 1 所示,訓練所有層的性能略好一些:96.67% 對 95.00%。(不過,這增加了運行時間約 2.5 倍。)
如果您熟悉 BERT 之類的編碼器式語言模型( Devlin 等人于 2018 年發表的《BERT:用于語言理解的深度雙向變壓器的預訓練》),您可能知道這些模型具有指定的分類標記作為它們的第一個標記,如下圖所示。
與 BERT 相比,GPT 是一種帶有因果注意掩碼的解碼器式模型(如前面的圖 7 所示)。這意味著第一個token沒有獲取到輸入中任何其他token的上下文信息,而只有最后一個token包含了關于所有其他token的信息。
因此,如果我們想使用像 GPT 這樣的模型進行分類微調,我們應該關注最后一個標記,以捕獲所有其他輸入標記的上下文信息。
下面是額外的實驗證據,我們可以看到,使用第一個標記來微調 GPT 模型進行分類會導致更差的性能。
總體而言,我仍然感到驚訝,第一個標記包含如此多的信息,能夠以 75% 的準確率確定郵件是否為垃圾郵件。(這并不是一個平衡的數據集,隨機分類器的準確率為 50%)。
說到 BERT,您可能想知道它與分類任務上的 GPT 風格模型相比如何。
簡而言之,上一節中的小型 GPT-2 模型和 BERT 在垃圾郵件分類數據集上的表現同樣出色,如下表所示。
請注意,BERT 模型的表現略好一些(測試準確率高出 1%),但規模也幾乎是 3 倍。另外,考慮到之前的數據集可能太小且相對簡單,我還嘗試使用IMDB電影評論數據集來進行情感分類任務,也就是預測評論者是否喜歡這部電影。
可以看出,GPT-2 和 BERT 這兩個模型在這個更大的數據集(由 25k 條訓練集記錄和 25k 條測試集記錄組成)上也具有相對相似的預測性能。
普遍的觀點是,在分類任務上,BERT等編碼器式模型的表現要優于解碼器式模型。但如上所述的實驗結果顯示,編碼器式的BERT與解碼器式的GPT模型之間的性能差異并不顯著。
此外,如果您對更多基準比較和進一步改進解碼器式分類模型的技巧感興趣,您可能會喜歡這兩篇最新論文:
例如,正如上述論文所討論的,可以通過在分類微調期間去除因果掩碼來進一步提高解碼器式模型的分類性能。
由于我們是在下一個詞預測任務上訓練類似GPT的模型,因此GPT架構的一個核心特性就是采用了因果注意力掩碼(這與BERT模型或原始的Transformer架構是有所不同的)。
然而,我們實際上可以在分類微調期間刪除因果掩碼,這將允許我們微調第一個而不是最后一個標記,因為未來的標記將不再被掩碼,并且第一個標記可以看到所有其他標記。
幸運的是,在類似 GPT 的 LLM 中禁用因果注意力掩碼只需要更改兩行代碼:
??class MultiheadAttention(nn.Module):
def __init__(self, d_in, d_out, context_length, dropout, num_heads):
super().__init__()
# ...
def forward(self, x):
b, num_tokens, d_in = x.shape
keys = self.W_key(x) # Shape: (b, num_tokens, d_out)
queries = self.W_query(x)
values = self.W_value(x)
# ...
attn_scores = queries @ keys.transpose(2, 3)
# Comment out the causal attention mask part
# mask_bool = self.mask.bool()[:num_tokens, :num_tokens]
# attn_scores.masked_fill_(mask_bool, -torch.inf)
attn_weights = torch.softmax(
attn_scores / keys.shape[-1]**0.5, dim=-1
)
context_vec = (attn_weights @ values).transpose(1, 2)
context_vec = context_vec.contiguous().view(
b, num_tokens, self.d_out
)
context_vec = self.out_proj(context_vec)
return context_vec
下表 5 顯示了此修改如何影響垃圾郵件分類任務的性能。
根據表 5 中的結果我們可以看出,當我們在微調期間禁用因果掩碼時,我們可以獲得小幅改進。
到目前為止,我們僅研究了最小的 GPT-2 模型(1.24 億個參數版本)的性能。與具有3.55億、7.74億和15億個參數的較大版本相比,它的表現如何呢?相關的結果已經總結在表6中。
我們可以看到,隨著模型規模的擴大,預測準確率顯著提高(然而,GPT-2 中等規模在這里是個例外。我也注意到該模型在其他數據集上的表現不佳,我懷疑該模型可能沒有經過很好的預訓練。)
然而,盡管GPT-2 XL模型在分類準確率上明顯優于最小模型,但其微調時間卻相應地延長了7倍。
對于第一個問題”我們需要訓練所有層嗎?”,我們發現,僅通過微調最后一個Transformer塊而非整個模型,我們就能(幾乎)達到相同的分類性能。僅微調最后一個塊的優點是訓練速度更快,因為并非所有權重參數都會更新。
接下來的問題是,將其與低秩自適應(LoRA)這種參數高效的微調技術相比較,結果會如何呢?(關于LoRA的詳細介紹,請參見附錄E。)
如上表 7 所示,完全微調(所有層)和 LoRA 在該數據集上均獲得相同的測試集性能。
在小型模型上,LoRA 稍微慢一些,因為添加 LoRA 層所帶來的額外開銷可能超過其帶來的好處,但在訓練更大的 15 億參數模型時,LoRA 的訓練速度要快 1.53 倍。
如果我們想要在訓練或推理期間批量處理數據(這涉及一次處理多個輸入序列),我們需要插入填充標記以確保訓練示例的長度相等。
在常規文本生成任務中,填充不會影響模型響應,因為填充標記通常添加到右側,并且由于前面討論的因果掩碼,這些填充標記不會影響其他標記。
但是,請注意,如前所述,我們的微調是基于最后一個標記的。由于填充標記位于最后一個標記的左側,因此它們可能會對結果產生影響。
如果我們使用的批處理大小為1,那么實際上就不需要對輸入進行填充。當然,從計算的角度來看,這樣的處理效率會更低,因為我們一次只能處理一個輸入示例。)不過,批處理大小 1 可以用作一種解決方法,以測試使用填充是否可以改善結果。(另一種解決方案是添加自定義掩碼以在注意力得分計算中忽略填充標記,但由于這需要更改 GPT 實現,所以這是另一個話題。
我們可以看到,避免使用填充標記確實可以給模型帶來明顯的提升!(請注意,我使用梯度累積來模擬批量大小為 8,以匹配默認實驗的批量大小,并進行公平的比較。)
本文展示了我的新書《從頭開始構建大型語言模型》第 6 章的 10 頁片段。
您所閱讀的內容只是“從頭開始構建類似GPT的大型語言模型(LLM)”這一整個365頁旅程中的一小部分,旨在幫助您深入了解LLM的實際工作原理。
如果這段摘錄引起了您的共鳴,您可能會發現本書的其余部分同樣富有洞察力且很有幫助。
原文鏈接:Building A GPT-Style LLM Classifier From Scratch