文章目錄
- 6. Fine-tuning for classification
- 6.1 Different categories of fine-tuning
- 6.2 Preparing the dataset
- 第一步:下載并解壓數據集
- 第二步:檢查類別標簽分布
- 第三步:創建平衡數據集
- 第四步:數據集拆分
- 6.3 Creating data loaders
- 創建 PyTorch Dataset
- 設置 PyTorch Dataset 類
- 工作原理
- 創建 PyTorch 數據加載器
- 6.4 Initializing a model with pretrained weights
- 加載預訓練的 GPT 模型
- 檢查模型是否能識別垃圾短信
- 6.5 Adding a classification head
- 使最后一個 Transformer 塊和 `LayerNorm` 可訓練
- 為什么關注最后一個輸出 token?
- 6.6 Calculating the classification loss and accuracy
- 計算分類準確率
- 定義分類損失函數
- 計算分類損失
- 6.7 Fine-tuning the model on supervised data
- 微調模型以分類垃圾郵件
- 繪制分類損失
- 6.8 Using the LLM as a spam classifier
- 使用模型分類新文本
6. Fine-tuning for classification
到目前為止,我們已經完成了 LLM(大語言模型)架構的編寫、預訓練,并學習了如何將來自外部來源(如 OpenAI)的預訓練權重導入到我們的模型中。現在,我們將通過對 LLM 進行微調以適應特定目標任務(例如文本分類)來收獲我們的勞動成果。我們具體研究的例子是將文本消息分類為“垃圾短信”或“非垃圾短信”。圖 6.1 突出了微調 LLM 的兩種主要方式:用于分類的微調(第 8 步)和用于執行指令的微調(第 9 步)。
6.1 Different categories of fine-tuning
語言模型的微調最常見的方式是指令微調和分類微調。指令微調是指使用一組任務及其對應的具體指令對語言模型進行訓練,以提高其理解和執行以自然語言提示描述的任務的能力,如圖6.2所示。
分類微調則是另一種方法,如果你有機器學習背景,可能已經熟悉這一概念。在分類微調中,模型被訓練以識別一組特定的類別標簽,例如“垃圾郵件”和“非垃圾郵件”。分類任務的應用范圍遠超LLM和電子郵件過濾,例如從圖像中識別不同的植物種類;將新聞文章分類為體育、政治、科技等主題;或者在醫學影像中區分良性和惡性腫瘤。
關鍵的一點是,分類微調的模型只能預測訓練中遇到過的類別。例如,它可以判斷某些內容是“垃圾郵件”還是“非垃圾郵件”(如圖6.3所示),但無法對輸入文本作出其他方面的判斷。
與圖6.3中分類微調模型的局限性不同,指令微調模型通常能夠完成更廣泛的任務。我們可以將分類微調模型視為高度專業化的模型,而開發一個在各種任務上表現良好的通用模型通常比開發一個專用模型更加困難。
如何選擇合適的方法
- 指令微調可以提高模型基于具體用戶指令理解和生成響應的能力。它更適合需要處理多種任務的模型,尤其是那些基于復雜用戶指令的任務,提高了靈活性和交互質量。分類微調則更適合需要將數據精準分類為預定義類別的項目,例如情感分析或垃圾郵件檢測。
- 雖然指令微調更加通用,但它需要更大的數據集和更多的計算資源來開發能夠勝任多種任務的模型。相比之下,分類微調所需的數據和計算資源較少,但其用途局限于模型已訓練過的特定類別。
6.2 Preparing the dataset
我們將對之前實現并預訓練的GPT模型進行修改和分類微調。首先,我們需要下載并準備數據集,如圖6.4所示。為了提供一個直觀且有用的分類微調示例,我們將使用一個包含垃圾短信和非垃圾短信的文本消息數據集。
注意:這里的文本消息通常是指通過手機發送的消息,而非電子郵件。然而,同樣的步驟也適用于電子郵件分類。感興趣的讀者可以在附錄B中找到電子郵件垃圾分類數據集的鏈接。
第一步:下載并解壓數據集
import urllib.request
import zipfile
import os
from pathlib import Pathurl = "https://archive.ics.uci.edu/static/public/228/sms+spam+collection.zip"
zip_path = "sms_spam_collection.zip"
extracted_path = "sms_spam_collection"
data_file_path = Path(extracted_path) / "SMSSpamCollection.tsv"def download_and_unzip_spam_data(url, zip_path, extracted_path, data_file_path):if data_file_path.exists():print(f"{data_file_path} already exists. Skipping download and extraction.")returnwith urllib.request.urlopen(url) as response:with open(zip_path, "wb") as out_file:out_file.write(response.read())with zipfile.ZipFile(zip_path, "r") as zip_ref:zip_ref.extractall(extracted_path)original_file_path = Path(extracted_path) / "SMSSpamCollection"os.rename(original_file_path, data_file_path)print(f"File downloaded and saved as {data_file_path}")download_and_unzip_spam_data(url, zip_path, extracted_path, data_file_path)
運行上述代碼后,數據集將保存為一個制表符分隔的文本文件SMSSpamCollection.tsv
,位于sms_spam_collection
文件夾中。我們可以通過以下代碼將其加載到Pandas數據框中:
import pandas as pd
df = pd.read_csv(data_file_path, sep="\t", header=None, names=["Label", "Text"]
)
df
在Jupyter Notebook中運行此代碼將渲染數據框,或者可以通過print(df)
來查看結果。圖6.5展示了垃圾短信數據集的數據框。
第二步:檢查類別標簽分布
運行以下代碼:
print(df["Label"].value_counts())
執行上述代碼后,我們可以發現數據集中“非垃圾”(ham
)遠多于“垃圾”(spam
):
Label
ham 4825
spam 747
Name: count, dtype: int64
為了簡單起見,同時為了便于使用較小的數據集(這將加快LLM的微調速度),我們選擇對數據集進行欠采樣,使每類包含747個實例。
注意:處理類別不平衡還有許多其他方法,但這些內容超出了本書范圍。讀者可以在附錄B中找到關于處理數據不平衡方法的更多信息。
第三步:創建平衡數據集
def create_balanced_dataset(df):num_spam = df[df["Label"] == "spam"].shape[0]ham_subset = df[df["Label"] == "ham"].sample(num_spam, random_state=123)balanced_df = pd.concat([ham_subset, df[df["Label"] == "spam"]])return balanced_dfbalanced_df = create_balanced_dataset(df)
print(balanced_df["Label"].value_counts())
執行上述代碼后,我們可以看到數據集中垃圾短信和非垃圾短信的數量相等:
Label
ham 747
spam 747
Name: count, dtype: int64
接下來,我們將字符串標簽“ham”和“spam”轉換為整數標簽0和1:
balanced_df["Label"] = balanced_df["Label"].map({"ham": 0, "spam": 1})
這個過程類似于將文本轉換為令牌ID,但這里我們只處理兩個令牌ID:0和1。
第四步:數據集拆分
接下來,我們創建一個random_split
函數,將數據集分為三部分:70%用于訓練,10%用于驗證,20%用于測試。這些比例在機器學習中非常常見,用于訓練、調整和評估模型。
def random_split(df, train_frac, validation_frac):df = df.sample(frac=1, random_state=123).reset_index(drop=True)train_end = int(len(df) * train_frac)validation_end = train_end + int(len(df) * validation_frac)train_df = df[:train_end]validation_df = df[train_end:validation_end]test_df = df[validation_end:]return train_df, validation_df, test_dftrain_df, validation_df, test_df = random_split(balanced_df, 0.7, 0.1
)
接著,我們將數據集保存為CSV文件,以便后續使用:
train_df.to_csv("train.csv", index=None)
validation_df.to_csv("validation.csv", index=None)
test_df.to_csv("test.csv", index=None)
到目前為止,我們已經完成了數據集的下載、平衡處理,并將其拆分為訓練和評估子集。接下來,我們將設置PyTorch數據加載器,用于訓練模型。
6.3 Creating data loaders
我們將開發與之前處理文本數據時概念上相似的 PyTorch 數據加載器。在之前的實現中,我們使用滑動窗口技術生成了統一大小的文本塊,并將其分組為批次以提高模型訓練效率。每個文本塊作為一個獨立的訓練實例。然而,現在我們使用的是包含長度不同的短信的數據集。要將這些短信分批處理,我們有兩種主要選擇:
- 選項1:將所有短信截斷為數據集中或批次中最短短信的長度。
- 選項2:將所有短信填充到數據集中或批次中最長短信的長度。
第一個選項計算成本較低,但如果較短的短信遠小于平均長度或最長長度,可能會導致顯著的信息丟失,從而降低模型性能。因此,我們選擇第二個選項,它可以保留所有短信的完整內容。
為實現批處理(將所有短信填充到數據集中最長短信的長度),我們在較短的短信中添加填充標記(padding token)。我們使用"<|endoftext|>"
作為填充標記。
然而,我們并不直接在每條短信后添加字符串"<|endoftext|>"
,而是向編碼后的文本消息添加對應的標記ID。例如,標記ID50256
對應"<|endoftext|>"
。我們可以通過使用tiktoken
包的GPT-2分詞器驗證標記ID是否正確:
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")
print(tokenizer.encode("<|endoftext|>", allowed_special={"<|endoftext|>"}))
執行上述代碼會返回[50256]
,證明50256
確實是"<|endoftext|>"
的標記ID。
創建 PyTorch Dataset
在實例化數據加載器之前,我們需要實現一個 PyTorch Dataset,用于指定如何加載和處理數據。為此,我們定義了一個 SpamDataset
類,該類的功能包括:
- 確定訓練數據集中最長序列的長度。
- 對短信進行編碼。
- 使用填充標記將其他序列填充到最長序列的長度。
設置 PyTorch Dataset 類
import torch
from torch.utils.data import Datasetclass SpamDataset(Dataset):def __init__(self, csv_file, tokenizer, max_length=None, pad_token_id=50256):self.data = pd.read_csv(csv_file)# 對文本進行預編碼self.encoded_texts = [tokenizer.encode(text) for text in self.data["Text"]]if max_length is None:self.max_length = self._longest_encoded_length()else:self.max_length = max_length# 截斷超出 max_length 的序列self.encoded_texts = [encoded_text[:self.max_length]for encoded_text in self.encoded_texts]# 填充序列到最長長度self.encoded_texts = [encoded_text + [pad_token_id] * (self.max_length - len(encoded_text))for encoded_text in self.encoded_texts]def __getitem__(self, index):encoded = self.encoded_texts[index]label = self.data.iloc[index]["Label"]return (torch.tensor(encoded, dtype=torch.long),torch.tensor(label, dtype=torch.long))def __len__(self):return len(self.data)def _longest_encoded_length(self):max_length = 0for encoded_text in self.encoded_texts:encoded_length = len(encoded_text)if encoded_length > max_length:max_length = encoded_lengthreturn max_length
工作原理
SpamDataset
類從我們之前創建的 CSV 文件中加載數據,使用 tiktoken
提供的 GPT-2 分詞器對文本進行標記化,并填充或截斷序列以統一長度。這確保每個輸入張量的大小一致,從而可以創建用于訓練的數據加載器。
我們可以這樣初始化訓練數據集:
train_dataset = SpamDataset(csv_file="train.csv",max_length=None,tokenizer=tokenizer
)
最長序列的長度存儲在數據集的 max_length
屬性中。如果想查看最長序列的令牌數量,可以運行以下代碼:
print(train_dataset.max_length)
輸出為120
,說明最長序列不超過120個標記。模型支持的上下文長度限制為1024個標記。如果數據集中有更長的文本,可以在創建訓練數據集時傳遞 max_length=1024
。
接下來,我們對驗證集和測試集進行填充,以匹配訓練集中最長序列的長度:
val_dataset = SpamDataset(csv_file="validation.csv",max_length=train_dataset.max_length,tokenizer=tokenizer
)
test_dataset = SpamDataset(csv_file="test.csv",max_length=train_dataset.max_length,tokenizer=tokenizer
)
使用這些數據集作為輸入,我們可以像處理文本數據時一樣實例化數據加載器。然而,在這種情況下,目標值表示的是類別標簽,而不是文本中的下一個標記。例如,如果我們選擇批量大小為8,每個批次將包含8個長度為120的訓練樣本及其對應的類別標簽,如圖6.7所示。
以下代碼創建了用于加載訓練集、驗證集和測試集的 PyTorch 數據加載器,這些加載器以批量大小為8的形式加載文本消息及其標簽。
創建 PyTorch 數據加載器
from torch.utils.data import DataLoadernum_workers = 0
batch_size = 8
torch.manual_seed(123)train_loader = DataLoader(dataset=train_dataset,batch_size=batch_size,shuffle=True,num_workers=num_workers,drop_last=True,
)val_loader = DataLoader(dataset=val_dataset,batch_size=batch_size,num_workers=num_workers,drop_last=False,
)test_loader = DataLoader(dataset=test_dataset,batch_size=batch_size,num_workers=num_workers,drop_last=False,
)
為驗證數據加載器是否返回預期大小的批次,可以遍歷訓練加載器并打印最后一個批次的張量維度:
for input_batch, target_batch in train_loader:pass
print("Input batch dimensions:", input_batch.shape)
print("Label batch dimensions", target_batch.shape)
輸出為:
Input batch dimensions: torch.Size([8, 120])
Label batch dimensions: torch.Size([8])
這表明每個輸入批次包含8條訓練示例,每條包含120個標記,標簽張量存儲8條訓練示例的類別標簽。
最后,打印每個數據集的總批次數量以了解數據集的大小:
print(f"{len(train_loader)} training batches")
print(f"{len(val_loader)} validation batches")
print(f"{len(test_loader)} test batches")
輸出為:
130 training batches
19 validation batches
38 test batches
數據準備完成后,我們可以著手準備模型以進行微調。
6.4 Initializing a model with pretrained weights
我們需要為模型的分類微調做好準備,以識別垃圾短信。我們首先初始化預訓練模型,如圖 6.8 所示。
為了開始模型準備過程,我們使用與在無標簽數據上進行預訓練時相同的配置:
CHOOSE_MODEL = "gpt2-small (124M)"
INPUT_PROMPT = "Every effort moves"BASE_CONFIG = {"vocab_size": 50257, # 詞匯表大小"context_length": 1024, # 上下文長度"drop_rate": 0.0, # Dropout 率"qkv_bias": True # Query-Key-Value 偏置
}model_configs = {"gpt2-small (124M)": {"emb_dim": 768, "n_layers": 12, "n_heads": 12},"gpt2-medium (355M)": {"emb_dim": 1024, "n_layers": 24, "n_heads": 16},"gpt2-large (774M)": {"emb_dim": 1280, "n_layers": 36, "n_heads": 20},"gpt2-xl (1558M)": {"emb_dim": 1600, "n_layers": 48, "n_heads": 25},
}BASE_CONFIG.update(model_configs[CHOOSE_MODEL])
接下來,我們從 gpt_download.py
文件中導入 download_and_load_gpt2
函數,并復用第 5 章中的 GPTModel
類和 load_weights_into_gpt
函數,將下載的權重加載到 GPT 模型中。
加載預訓練的 GPT 模型
from gpt_download import download_and_load_gpt2
from chapter05 import GPTModel, load_weights_into_gptmodel_size = CHOOSE_MODEL.split(" ")[-1].lstrip("(").rstrip(")")
settings, params = download_and_load_gpt2(model_size=model_size, models_dir="gpt2"
)model = GPTModel(BASE_CONFIG)
load_weights_into_gpt(model, params)
model.eval()
將模型權重加載到 GPTModel
后,我們可以復用第 4 和 5 章的文本生成工具函數,來驗證模型是否能夠生成連貫的文本,確保權重加載正確:
from chapter04 import generate_text_simple
from chapter05 import text_to_token_ids, token_ids_to_texttext_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
從輸出可以看出,模型難以按照指令進行操作。這是預期的結果,因為模型僅經過預訓練,尚未進行指令微調。
由于模型尚未進行指令微調,我們需要對模型進行分類微調以適應垃圾短信分類任務。這將是接下來的重點步驟。
6.5 Adding a classification head
我們必須對預訓練的 LLM 進行修改,以便為分類微調做準備。具體來說,我們將原始的輸出層(將隱藏表示映射到大小為 50,257 的詞匯表)替換為一個更小的輸出層,該輸出層將隱藏表示映射到兩個類別:0(“非垃圾”)和 1(“垃圾”),如圖 6.9 所示。除了更換輸出層外,其余模型保持不變。
輸出層節點
- 盡管我們可以技術上使用一個單一的輸出節點(因為這是一個二分類任務),但這需要修改損失函數(相關內容參見《Losses Learned—Optimizing Negative Log-Likelihood and Cross-Entropy in PyTorch》 https://mng.bz/NRZ2)。因此,我們選擇一種更通用的方法:輸出節點的數量等于類別數量。例如,對于一個三分類問題(如將新聞文章分類為“科技”、“體育”或“政治”),我們將使用三個輸出節點,以此類推。
在進行圖 6.9 所示的修改之前,我們可以通過 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)
)
輸出清晰地展示了 GPT 模型的架構(如第 4 章所述)。GPTModel
包括嵌入層(tok_emb
和 pos_emb
),隨后是 12 個相同的 Transformer 塊(為了簡潔,僅展示最后一個塊),最終是一個 LayerNorm
和輸出層(out_head
)。
接下來,我們將 out_head
替換為一個新的輸出層(參見圖 6.9),并對其進行微調。
微調選擇:只調整部分層還是調整所有層?
- 由于我們從預訓練模型開始,因此不需要微調模型的所有層。在基于神經網絡的語言模型中,底層通常捕獲適用于廣泛任務和數據集的基本語言結構和語義。因而,僅微調靠近輸出層的最后幾層(捕獲更細致的語言模式和任務特定特征)通常足以讓模型適應新任務。
- 這種方法的另一個好處是,只微調少量層在計算上更高效。更多關于微調哪些層的實驗和信息可以參見附錄 B。
為了準備分類微調,我們首先凍結模型,使所有層變為不可訓練:
for param in model.parameters():param.requires_grad = False
然后,我們將輸出層(model.out_head
)替換為一個新的輸出層,其將輸入映射到兩個維度(類別數為 2):
torch.manual_seed(123)
num_classes = 2
model.out_head = torch.nn.Linear(in_features=BASE_CONFIG["emb_dim"], # 嵌入維度,gpt2-small 為 768out_features=num_classes
)
為了保持代碼通用,我們使用了 BASE_CONFIG["emb_dim"]
,它在 gpt2-small (124M)
模型中等于 768。這使得代碼也能適用于更大的 GPT-2 模型變體。
新的 model.out_head
輸出層的 requires_grad
屬性默認設置為 True
,這意味著它是模型中唯一會在訓練中更新的層。
使最后一個 Transformer 塊和 LayerNorm
可訓練
盡管訓練新增的輸出層已經足夠,但實驗表明,微調額外的層可以顯著提高模型的預測性能(詳見附錄 B)。因此,我們還配置最后一個 Transformer 塊和連接該塊與輸出層的最終 LayerNorm
模塊為可訓練狀態:
for param in model.trf_blocks[-1].parameters():param.requires_grad = Truefor 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)
輸出:
Inputs: tensor([[5211, 345, 423, 640]])
Inputs dimensions: torch.Size([1, 4])
隨后,將編碼后的 token 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])
注意,輸出的張量維度為 [1, 4, 2]
,而不是之前的 [1, 4, 50257]
。輸出的行數與輸入 token 數相同(本例為 4),但每行的嵌入維度現在是 2(類別數)而非 50,257(詞匯表大小)。
為什么關注最后一個輸出 token?
請記住,我們的目標是對模型進行微調,以輸出一個類別標簽,指示輸入是“垃圾郵件”還是“非垃圾郵件”。我們不需要對所有四個輸出行進行微調,而是可以專注于一個輸出標記。具體來說,我們將關注最后一行,對應于最后一個輸出標記,如圖 6.11 所示。
我們可以使用以下代碼提取最后一個輸出 token:
print("Last output token:", outputs[:, -1, :])
輸出:
Last output token: tensor([[-3.5983, 3.9902]])
我們仍然需要將輸出的值轉換為類別標簽預測。但首先,讓我們了解為什么特別關注最后一個輸出標記。
我們已經探討了注意力機制,它在每個輸入標記和其他輸入標記之間建立了關聯,以及因果注意力掩碼(causal attention mask)的概念,這在類似 GPT 的模型中被廣泛使用(參見第 3 章)。這種掩碼限制了每個標記的關注范圍,使其只能關注當前標記及其之前的標記,確保每個標記只受到自身和之前標記的影響,如圖 6.12 所示。
基于圖 6.12 所示的因果注意力掩碼設置,序列中的最后一個標記累積了最多的信息,因為它是唯一一個可以訪問所有先前標記數據的標記。因此,在垃圾郵件分類任務中,我們在微調過程中專注于這個最后的標記。
現在,我們準備將最后一個標記的輸出轉換為類別標簽預測,并計算模型的初始預測準確率。隨后,我們將對模型進行垃圾郵件分類任務的微調。
6.6 Calculating the classification loss and accuracy
在對模型進行微調之前,還有一個小任務需要完成:實現微調過程中使用的模型評估函數,如圖 6.13 所示。
在實現評估工具之前,讓我們簡單討論一下如何將模型的輸出轉換為類別標簽預測。此前,我們通過將 50,257 維的輸出使用 softmax 函數轉換為概率,然后通過 argmax 函數返回最高概率的位置來計算 LLM 生成的下一個標記的 token ID。這里我們使用相同的方法來計算模型是否預測給定輸入為“垃圾郵件”或“非垃圾郵件”,如圖 6.14 所示。唯一的區別是,我們處理的是二維輸出,而不是 50,257 維的輸出。
讓我們通過一個具體的例子來看最后一個輸出標記:
print("Last output token:", outputs[:, -1, :])
對應于最后一個標記的張量值是:
Last output token: tensor([[-3.5983, 3.9902]])
我們可以通過以下代碼獲得類別標簽:
probas = torch.softmax(outputs[:, -1, :], dim=-1)
label = torch.argmax(probas)
print("Class label:", label.item())
在這種情況下,代碼返回 1
,這意味著模型預測輸入文本為“垃圾郵件”。在這里使用 softmax 函數是可選的,因為輸出中最大的值直接對應于最高的概率分數。因此,可以不使用 softmax 來簡化代碼:
logits = outputs[:, -1, :]
label = torch.argmax(logits)
print("Class label:", label.item())
這個方法可以用來計算分類準確率,即整個數據集中正確預測的百分比。
為了確定分類準確率,我們對數據集中的所有示例應用基于 argmax 的預測代碼,并通過定義 calc_accuracy_loader
函數計算正確預測的比例。
計算分類準確率
def calc_accuracy_loader(data_loader, model, device, num_batches=None):model.eval()correct_predictions, num_examples = 0, 0if num_batches is None:num_batches = len(data_loader)else:num_batches = min(num_batches, len(data_loader))for i, (input_batch, target_batch) in enumerate(data_loader):if i < num_batches:input_batch = input_batch.to(device)target_batch = target_batch.to(device)with torch.no_grad():logits = model(input_batch)[:, -1, :] # 取最后一個標記的 logitspredicted_labels = torch.argmax(logits, dim=-1) # 預測標簽num_examples += predicted_labels.shape[0]correct_predictions += ((predicted_labels == target_batch).sum().item())else:breakreturn correct_predictions / num_examples
使用該函數可以高效地估算各種數據集上的分類準確率,默認處理 10 個批次:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)
torch.manual_seed(123)train_accuracy = calc_accuracy_loader(train_loader, model, device, num_batches=10
)
val_accuracy = calc_accuracy_loader(val_loader, model, device, num_batches=10
)
test_accuracy = calc_accuracy_loader(test_loader, model, device, num_batches=10
)print(f"Training accuracy: {train_accuracy*100:.2f}%")
print(f"Validation accuracy: {val_accuracy*100:.2f}%")
print(f"Test accuracy: {test_accuracy*100:.2f}%")
通過設備設置,模型會自動在支持 Nvidia CUDA 的 GPU 上運行,否則將在 CPU 上運行。輸出如下:
Training accuracy: 46.25%
Validation accuracy: 45.00%
Test accuracy: 48.75%
正如我們所見,預測準確率接近隨機預測的水平(本例中為 50%)。為了提高預測準確率,我們需要對模型進行微調。
定義分類損失函數
在開始微調模型之前,我們需要定義訓練過程中優化的損失函數。我們的目標是最大化模型的垃圾郵件分類準確率,這意味著模型應該輸出正確的類別標簽:0(非垃圾郵件)或 1(垃圾郵件)。
由于分類準確率不是可微函數,我們使用交叉熵損失作為替代,以間接最大化準確率。為此,calc_loss_batch
函數保持不變,但有一個調整:我們只優化最后一個標記的輸出(model(input_batch)[:, -1, :]
),而不是所有標記的輸出(model(input_batch)
):
def calc_loss_batch(input_batch, target_batch, model, device):input_batch = input_batch.to(device)target_batch = target_batch.to(device)logits = model(input_batch)[:, -1, :] # 取最后一個標記的 logitsloss = torch.nn.functional.cross_entropy(logits, target_batch)return loss
我們使用 calc_loss_batch
函數計算從先前定義的數據加載器中獲取的單個批次的損失。為了計算整個數據加載器的所有批次的損失,我們像以前一樣定義 calc_loss_loader
函數。
計算分類損失
def calc_loss_loader(data_loader, model, device, num_batches=None):total_loss = 0.if len(data_loader) == 0:return float("nan")elif num_batches is None:num_batches = len(data_loader)else:num_batches = min(num_batches, len(data_loader))for i, (input_batch, target_batch) in enumerate(data_loader):if i < num_batches:loss = calc_loss_batch(input_batch, target_batch, model, device)total_loss += loss.item()else:breakreturn total_loss / num_batches
與計算訓練準確率類似,我們現在計算每個數據集的初始損失:
with torch.no_grad():train_loss = calc_loss_loader(train_loader, model, device, num_batches=5)val_loss = calc_loss_loader(val_loader, model, device, num_batches=5)test_loss = calc_loss_loader(test_loader, model, device, num_batches=5)print(f"Training loss: {train_loss:.3f}")
print(f"Validation loss: {val_loss:.3f}")
print(f"Test loss: {test_loss:.3f}")
初始損失值:
Training loss: 2.453
Validation loss: 2.583
Test loss: 2.322
接下來,我們將實現一個訓練函數,通過最小化訓練集的損失來微調模型。最小化訓練集損失將有助于提高分類準確率,這是我們的最終目標。
6.7 Fine-tuning the model on supervised data
我們必須定義并使用訓練函數來對預訓練的 LLM 進行微調,并提高其垃圾郵件分類準確率。訓練循環,如圖 6.15 所示,是我們在預訓練時使用的相同訓練循環;唯一的區別是我們計算的是分類準確率,而不是生成示例文本來評估模型。
實現圖 6.15 中概念的訓練函數與我們用于預訓練模型的 train_model_simple
函數非常相似。唯一的兩點區別是,現在我們追蹤的是看到的訓練示例數量(examples_seen
),而不是標記數量,并且我們在每個 epoch 后計算準確率,而不是打印示例文本。
微調模型以分類垃圾郵件
# 初始化用于追蹤損失和已見示例的列表
def train_classifier_simple(model, train_loader, val_loader, optimizer, device,num_epochs, eval_freq, eval_iter):train_losses, val_losses, train_accs, val_accs = [], [], [], []examples_seen, global_step = 0, -1# 主訓練循環for epoch in range(num_epochs):model.train() # 將模型設置為訓練模式# 重置損失梯度for input_batch, target_batch in train_loader:optimizer.zero_grad()loss = calc_loss_batch(input_batch, target_batch, model, device)loss.backward()optimizer.step()examples_seen += input_batch.shape[0]global_step += 1# 可選評估步驟if global_step % eval_freq == 0:train_loss, val_loss = evaluate_model(model, train_loader, val_loader, device, eval_iter)train_losses.append(train_loss)val_losses.append(val_loss)print(f"Ep {epoch+1} (Step {global_step:06d}): "f"Train loss {train_loss:.3f}, "f"Val loss {val_loss:.3f}")# 計算準確率train_accuracy = calc_accuracy_loader(train_loader, model, device, num_batches=eval_iter)val_accuracy = calc_accuracy_loader(val_loader, model, device, num_batches=eval_iter)print(f"Training accuracy: {train_accuracy*100:.2f}% | ", end="")print(f"Validation accuracy: {val_accuracy*100:.2f}%")train_accs.append(train_accuracy)val_accs.append(val_accuracy)return train_losses, val_losses, train_accs, val_accs, examples_seen
evaluate_model
函數與我們用于預訓練時使用的完全相同:
def evaluate_model(model, train_loader, val_loader, device, eval_iter):model.eval()with torch.no_grad():train_loss = calc_loss_loader(train_loader, model, device, num_batches=eval_iter)val_loss = calc_loss_loader(val_loader, model, device, num_batches=eval_iter)model.train()return train_loss, val_loss
接下來,我們初始化優化器,設置訓練 epoch 數,并使用 train_classifier_simple
函數開始訓練。訓練大約需要 6 分鐘時間(在 M3 MacBook Air 上),在 V100 或 A100 GPU 上則需要不到半分鐘時間:
import time
start_time = time.time()
torch.manual_seed(123)
optimizer = torch.optim.AdamW(model.parameters(), lr=5e-5, weight_decay=0.1)
num_epochs = 5
train_losses, val_losses, train_accs, val_accs, examples_seen = \
train_classifier_simple(model, train_loader, val_loader, optimizer, device,num_epochs=num_epochs, eval_freq=50,eval_iter=5
)
end_time = time.time()
execution_time_minutes = (end_time - start_time) / 60
print(f"Training completed in {execution_time_minutes:.2f} minutes.")
在訓練過程中,我們會看到如下輸出:
Ep 1 (Step 000000): Train loss 2.153, Val loss 2.392
Ep 1 (Step 000050): Train loss 0.617, Val loss 0.637
Ep 1 (Step 000100): Train loss 0.523, Val loss 0.557
Training accuracy: 70.00% | Validation accuracy: 72.50%
Ep 2 (Step 000150): Train loss 0.561, Val loss 0.489
Ep 2 (Step 000200): Train loss 0.419, Val loss 0.397
Ep 2 (Step 000250): Train loss 0.409, Val loss 0.353
Training accuracy: 82.50% | Validation accuracy: 85.00%
Ep 3 (Step 000300): Train loss 0.333, Val loss 0.320
Ep 3 (Step 000350): Train loss 0.340, Val loss 0.306
Training accuracy: 90.00% | Validation accuracy: 90.00%
Ep 4 (Step 000400): Train loss 0.136, Val loss 0.200
Ep 4 (Step 000450): Train loss 0.153, Val loss 0.132
Ep 4 (Step 000500): Train loss 0.222, Val loss 0.137
Training accuracy: 100.00% | Validation accuracy: 97.50%
Ep 5 (Step 000550): Train loss 0.207, Val loss 0.143
Ep 5 (Step 000600): Train loss 0.083, Val loss 0.074
Training accuracy: 100.00% | Validation accuracy: 97.50%
Training completed in 5.65 minutes.
接下來,我們使用 Matplotlib 繪制訓練集和驗證集的損失函數。
繪制分類損失
import matplotlib.pyplot as pltdef plot_values(epochs_seen, examples_seen, train_values, val_values,label="loss"):fig, ax1 = plt.subplots(figsize=(5, 3))ax1.plot(epochs_seen, train_values, label=f"Training {label}")ax1.plot(epochs_seen, val_values, linestyle="-.",label=f"Validation {label}")ax1.set_xlabel("Epochs")ax1.set_ylabel(label.capitalize())ax1.legend()ax2 = ax1.twiny()ax2.plot(examples_seen, train_values, alpha=0)ax2.set_xlabel("Examples seen")fig.tight_layout()plt.savefig(f"{label}-plot.pdf")plt.show()epochs_tensor = torch.linspace(0, num_epochs, len(train_losses))
examples_seen_tensor = torch.linspace(0, examples_seen, len(train_losses))
plot_values(epochs_tensor, examples_seen_tensor, train_losses, val_losses)
圖 6.16 展示了結果的損失曲線。
正如我們從圖 6.16 中明顯的下降趨勢中看到的那樣,模型在訓練數據上學習得很好,并且幾乎沒有過擬合的跡象;也就是說,訓練集和驗證集的損失之間沒有明顯的差距。
之前在啟動訓練時,我們將 epoch 數量設置為 5。epoch 的數量取決于數據集和任務的難度,沒有通用的解決方案或推薦,盡管五個 epoch 通常是一個不錯的起點。如果模型在前幾個 epoch 后發生過擬合(如損失圖所示,見圖 6.16),則可能需要減少 epoch 數量。相反,如果趨勢線表明驗證損失隨著進一步訓練可以改善,則應該增加 epoch 數量。在這個具體的案例中,五個 epoch 是合理的,因為沒有出現早期過擬合的跡象,并且驗證損失接近 0。
使用相同的 plot_values
函數,現在讓我們繪制分類準確率:
epochs_tensor = torch.linspace(0, num_epochs, len(train_accs))
examples_seen_tensor = torch.linspace(0, examples_seen, len(train_accs))
plot_values(epochs_tensor, examples_seen_tensor, train_accs, val_accs,label="accuracy"
)
圖 6.17 展示了結果的準確率曲線。模型在第 4 和第 5 個 epoch 后實現了相對較高的訓練和驗證準確率。值得注意的是,我們在使用 train_classifier_simple
函數時之前將 eval_iter=5
設置為 5,這意味著我們在訓練期間對訓練和驗證性能的估算僅基于 5 個批次,以提高訓練效率。
現在,我們必須通過運行以下代碼來計算整個數據集上訓練、驗證和測試集的性能指標,這次不定義 eval_iter
值:
train_accuracy = calc_accuracy_loader(train_loader, model, device)
val_accuracy = calc_accuracy_loader(val_loader, model, device)
test_accuracy = calc_accuracy_loader(test_loader, model, device)
print(f"Training accuracy: {train_accuracy*100:.2f}%")
print(f"Validation accuracy: {val_accuracy*100:.2f}%")
print(f"Test accuracy: {test_accuracy*100:.2f}%")
結果準確率:
Training accuracy: 97.21%
Validation accuracy: 97.32%
Test accuracy: 95.67%
訓練集和測試集的性能幾乎完全相同。訓練集和測試集準確率之間的輕微差異表明訓練數據的過擬合非常小。通常,驗證集的準確率會略高于測試集的準確率,因為模型開發過程中通常會調整超參數,以便在驗證集上表現良好,而這種調整可能并不總是能有效地推廣到測試集上。這種情況很常見,但通過調整模型設置(例如增加 dropout 率 drop_rate
或優化器配置中的 weight_decay
參數),可以最大限度地減少這種差距。
6.8 Using the LLM as a spam classifier
在完成模型的微調和評估后,我們現在可以開始分類垃圾短信(見圖 6.18)。
讓我們使用我們微調后的基于 GPT 的垃圾郵件分類模型。以下 classify_review
函數遵循類似于我們在先前實現的 SpamDataset
中使用的數據預處理步驟。然后,在將文本處理為 token ID 后,該函數使用模型預測一個整數類別標簽,類似于我們在第 6.6 節中實現的內容,并返回相應的類別名稱。
使用模型分類新文本
def classify_review(text, model, tokenizer, device, max_length=None,pad_token_id=50256):model.eval() # 設置模型為評估模式# 準備輸入input_ids = tokenizer.encode(text)supported_context_length = model.pos_emb.weight.shape[1]# 如果序列太長,則截斷input_ids = input_ids[:min(max_length, supported_context_length)]# 填充序列到最大長度input_ids += [pad_token_id] * (max_length - len(input_ids))input_tensor = torch.tensor(input_ids, device=device).unsqueeze(0) # 添加批次維度with torch.no_grad(): # 在不計算梯度的情況下進行推理logits = model(input_tensor)[:, -1, :]predicted_label = torch.argmax(logits, dim=-1).item()return "spam" if predicted_label == 1 else "not spam" # 返回分類結果
讓我們在一個示例文本上嘗試這個 classify_review
函數:
text_1 = ("You are a winner you have been specially"" selected to receive $1000 cash or a $2000 award."
)
print(classify_review(text_1, model, tokenizer, device, max_length=train_dataset.max_length
))
模型正確地預測為“垃圾郵件”。我們再試一個示例:
text_2 = ("Hey, just wanted to check if we're still on"" for dinner tonight? Let me know!"
)
print(classify_review(text_2, model, tokenizer, device, max_length=train_dataset.max_length
))
模型再次做出正確的預測,并返回“非垃圾郵件”標簽。
最后,為了以后能夠重用模型而無需重新訓練,我們可以保存模型。使用 torch.save
方法:
torch.save(model.state_dict(), "review_classifier.pth")
保存后,可以加載模型:
model_state_dict = torch.load("review_classifier.pth", map_location=device)
model.load_state_dict(model_state_dict)