預訓練權重是大語言模型的根本,能有效提升模型效能。本文將示範如何將 OpenAI 的預訓練權過載入 GPT 模型,並以垃圾郵件分類別為例,講解如何微調模型以執行特定任務。首先,我們會仔細比對 OpenAI 權重與自建 GPT 模型的結構,確保權重正確載入至對應的層。接著,使用 generate 函式生成文字,驗證模型的載入是否成功。文章進一步探討指令微調與分類別微調的差異,並以垃圾郵件分類別為例,示範如何準備與平衡資料集。最後,我們將詳細說明如何使用 PyTorch 建立 Dataset 和 DataLoader,以有效載入和預處理文字資料,為模型微調提供穩定的資料流。

載入與測試預訓練權重

在前面的章節中,我們實作了GPT模型的架構,並學習瞭如何從外部來源(如OpenAI)匯入預訓練權重到我們的模型中。現在,我們將透過將OpenAI的模型權過載入我們的GPT模型例項gpt來進行實際操作。

載入權重的過程

load_weights_into_gpt函式中,我們小心地將OpenAI實作中的權重與我們的GPTModel實作進行比對。例如,OpenAI將第一個轉換器區塊的輸出投影層的權重張量儲存為params["blocks"][0]["attn"]["c_proj"]["w"]。在我們的實作中,這個權重張量對應於gpt.trf_blocks[b].att.out_proj.weight,其中gpt是GPTModel的例項。

程式碼實作

def load_weights_into_gpt(gpt, params):
    # 省略其他權過載入程式碼...
    gpt.trf_blocks[b].att.out_proj.weight = assign(
        gpt.trf_blocks[b].att.out_proj.weight,
        params["blocks"][b]["attn"]["c_proj"]["w"].T)
    # ...其他權過載入
    return gpt

內容解密:

  1. load_weights_into_gpt函式的目的是將預訓練的權過載入到GPT模型中。
  2. 透過assign函式,將OpenAI的權重張量指定給對應的GPTModel層。
  3. params["blocks"][b]["attn"]["c_proj"]["w"].T表示OpenAI儲存的權重張量,需要轉置(.T)以比對我們的模型架構。

測試載入的模型

載入權重後,我們可以使用之前的generate函式來生成新的文字,以測試模型是否正確載入:

load_weights_into_gpt(gpt, params)
gpt.to(device)

torch.manual_seed(123)
token_ids = generate(
    model=gpt,
    idx=text_to_token_ids("Every effort moves you", tokenizer).to(device),
    max_new_tokens=25,
    context_size=NEW_CONFIG["context_length"],
    top_k=50,
    temperature=1.5
)
print("Output text:\n", token_ids_to_text(token_ids, tokenizer))

內容解密:

  1. 首先呼叫load_weights_into_gpt函式將權過載入GPT模型。
  2. 將模型移動到指定的裝置(例如GPU)。
  3. 使用generate函式生成新的文字,設定種子以保證可復現性。
  4. text_to_token_ids將輸入文字轉換為token IDs,並傳送到指定的裝置。
  5. 設定生成文字的最大長度、上下文大小、top-k取樣和溫度引數。

結果分析

如果模型正確載入,生成的文字應該是連貫且有意義的。否則,可能表示權過載入過程中出現錯誤。

重點回顧

 當大語言模型(LLM)生成文字時,它們一次輸出一個token。  預設情況下,下一個token是透過將模型輸出轉換為機率分數並選擇詞彙表中對應最高機率分數的token來生成的,這被稱為“貪婪解碼”。  使用機率取樣和溫度縮放,我們可以影響生成文字的多樣性和連貫性。  訓練和驗證集損失可以用來衡量LLM在訓練過程中生成的文字品質。

練習題

  1. 計算使用OpenAI預訓練權重的GPTModel在“The Verdict”資料集上的訓練和驗證集損失。
  2. 試驗不同大小的GPT-2模型(例如,最大1,558百萬引數模型),並比較生成的文字與1.24億引數模型的結果。

微調大語言模型以進行分類別任務

6.1 不同型別的微調方法

大語言模型(LLM)的微調主要有兩種常見方式:指令微調(instruction fine-tuning)和分類別微調(classification fine-tuning)。指令微調涉及使用特定指令訓練語言模型,以提高其理解和執行自然語言提示中描述任務的能力,如圖6.2所示。

分類別微調是將模型訓練以識別特定的類別標籤,例如“垃圾郵件”和“非垃圾郵件”。分類別任務的例子包括從影像中識別不同的植物物種、將新聞文章分類別為體育、政治和科技等主題,以及在醫學影像中區分良性和惡性腫瘤。

分類別微調的模型僅限於預測其在訓練期間遇到的類別。例如,它可以判斷某個文字是否為“垃圾郵件”或“非垃圾郵件”,如圖6.3所示,但無法對輸入文字做出其他回應。

選擇合適的微調方法

指令微調提高了模型理解和生成根據特定使用者指令的回應的能力。它適用於需要處理根據複雜使用者指令的多種任務的模型,從而提高模型的靈活性和互動品質。分類別微調則適用於需要將資料精確分類別到預定義類別的專案,例如情感分析和垃圾郵件檢測。

雖然指令微調更為通用,但它需要更大的資料集和更多的計算資源來開發能夠勝任各種任務的模型。相比之下,分類別微調需要較少的資料和計算能力,但其使用僅限於模型已訓練的特定類別。

6.2 準備資料集

我們將修改並對之前實作和預訓練的GPT模型進行分類別微調。首先,我們下載並準備資料集,如圖6.4所示。為了提供一個直觀且有用的分類別微調例子,我們將使用一個包含垃圾和非垃圾訊息的簡訊資料集。

下載資料集

import urllib.request
import zipfile
import os
from pathlib import Path

url = "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("Dataset already exists.")
        return
    
    # 下載資料集
    urllib.request.urlretrieve(url, zip_path)
    
    # 解壓縮資料集
    with zipfile.ZipFile(zip_path, 'r') as zip_ref:
        zip_ref.extractall(extracted_path)
    
    # 移除壓縮檔案
    os.remove(zip_path)

download_and_unzip_spam_data(url, zip_path, extracted_path, data_file_path)

內容解密:

  1. 下載資料集:首先檢查資料集是否已經存在。如果不存在,則使用urllib.request.urlretrieve下載資料集的壓縮檔案。
  2. 解壓縮資料集:使用zipfile.ZipFile解壓縮下載的壓縮檔案到指定目錄。
  3. 移除壓縮檔案:解壓縮完成後,移除下載的壓縮檔案以節省空間。

此步驟為準備資料集的第一步,確保我們擁有所需的資料以進行後續的模型微調。接下來的步驟包括預處理資料集、建立資料載入器、初始化模型、載入預訓練權重、修改模型以進行微調、實作評估工具、微調模型、評估微調後的模型以及在新資料上使用模型。

6.2 準備資料集

首先,我們檢查資料檔案是否存在,如果已存在,則跳過下載和解壓縮的步驟。

print(f"{data_file_path} 已經存在。跳過下載和解壓縮。")
return

接下來,我們使用 urllib.request.urlopen 下載資料集,並將其儲存到指定的 ZIP 檔案路徑。

with urllib.request.urlopen(url) as response:
    with open(zip_path, "wb") as out_file:
        out_file.write(response.read())

下載完成後,我們使用 zipfile.ZipFile 解壓縮 ZIP 檔案到指定的路徑。

with zipfile.ZipFile(zip_path, "r") as zip_ref:
    zip_ref.extractall(extracted_path)

內容解密:

  1. 檢查檔案是否存在:程式碼首先檢查目標資料檔案是否存在,如果存在,則直接傳回,跳過下載和解壓縮步驟。
  2. 下載資料集:使用 urllib.request.urlopen 開啟指定的 URL 連結,下載資料集。
  3. 儲存 ZIP 檔案:將下載的資料寫入到指定的 ZIP 檔案路徑。
  4. 解壓縮 ZIP 檔案:使用 zipfile.ZipFile 將下載的 ZIP 檔案解壓縮到指定的路徑。

下載並解壓縮完成後,我們將原始檔案重新命名為 SMSSpamCollection.tsv,並儲存在 sms_spam_collection 資料夾中。

original_file_path = Path(extracted_path) / "SMSSpamCollection"
os.rename(original_file_path, data_file_path)
print(f"檔案已下載並儲存為 {data_file_path}")

內容解密:

  1. 重新命名檔案:將解壓縮後的原始檔案重新命名為 SMSSpamCollection.tsv
  2. 儲存檔案:將檔案儲存在指定的路徑下。

接下來,我們將資料集載入到 pandas DataFrame 中。

import pandas as pd
df = pd.read_csv(data_file_path, sep="\t", header=None, names=["Label", "Text"])

內容解密:

  1. 載入資料集:使用 pd.read_csvSMSSpamCollection.tsv 檔案載入到 DataFrame 中。
  2. 指定欄位名稱:將欄位名稱指定為 LabelText

檢查類別標籤分佈

我們檢查資料集中類別標籤的分佈情況。

print(df["Label"].value_counts())

輸出結果顯示,資料集中 ham(非垃圾郵件)的數量遠多於 spam(垃圾郵件)。

內容解密:

  1. 統計類別標籤數量:使用 value_counts() 方法統計 Label 欄位中不同類別標籤的數量。
  2. 輸出結果:輸出結果顯示 ham 的數量為 4825,而 spam 的數量為 747。

為了簡化問題並加快模型的微調過程,我們選擇對資料集進行欠取樣,以使兩個類別的樣本數量相等。

建立平衡資料集

我們定義一個函式 create_balanced_dataset 來建立平衡的資料集。

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_df

balanced_df = create_balanced_dataset(df)
print(balanced_df["Label"].value_counts())

內容解密:

  1. 計算垃圾郵件數量:計算資料集中 spam 的數量。
  2. 隨機取樣非垃圾郵件:從 ham 中隨機取樣與 spam 相同數量的樣本。
  3. 合併資料:將取樣得到的 ham 子集與所有的 spam 合併,得到平衡的資料集。

將類別標籤轉換為整數

接下來,我們將類別標籤從字串轉換為整數。

balanced_df["Label"] = balanced_df["Label"].map({"ham": 0, "spam": 1})

內容解密:

  1. 對映類別標籤:將 ham 對映為 0,將 spam 對映為 1。

6.3 建立資料載入器

切分資料集

我們定義一個函式 random_split 將資料集切分為訓練集、驗證集和測試集。

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_df

train_df, validation_df, test_df = random_split(balanced_df, 0.7, 0.1)

內容解密:

  1. 隨機打亂資料集:使用 sample 方法隨機打亂 DataFrame 中的樣本順序。
  2. 計算切分點:根據指定的比例計算訓練集、驗證集和測試集的切分點。
  3. 切分資料集:根據切分點將 DataFrame 切分為訓練集、驗證集和測試集。

儲存資料集

最後,我們將切分好的資料集儲存為 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)

內容解密:

  1. 儲存訓練集:將訓練集儲存為 train.csv 檔案。
  2. 儲存驗證集:將驗證集儲存為 validation.csv 檔案。
  3. 儲存測試集:將測試集儲存為 test.csv 檔案。

建立資料載入器以進行分類別微調

在進行分類別任務的模型微調時,資料的準備和載入是至關重要的步驟。為了有效地處理文字資料,我們需要實作一個 PyTorch 的 Dataset 類別,負責載入和預處理資料。本章節將詳細介紹如何建立 SpamDataset 類別以及使用它來建立訓練、驗證和測試資料載入器。

文字預處理與填充

在處理變長的文字序列時,為了能夠批次處理資料,我們需要將所有序列填充到相同的長度。這可以透過在較短的序列後面新增填充標記(padding token)來實作。在本例中,我們使用 <|endoftext|> 作為填充標記,其對應的標記 ID 為 50256。

程式碼實作:

import torch
from torch.utils.data import Dataset
import tiktoken
import pandas as pd

# 定義 SpamDataset 類別
class 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
        
        # 對序列進行截斷和填充
        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 = 0
        for encoded_text in self.encoded_texts:
            encoded_length = len(encoded_text)
            if encoded_length > max_length:
                max_length = encoded_length
        return max_length

# 初始化 tokenizer
tokenizer = tiktoken.get_encoding("gpt2")

# 建立訓練資料集
train_dataset = SpamDataset(csv_file="train.csv", max_length=None, tokenizer=tokenizer)
print(train_dataset.max_length)  # 輸出最長序列的長度

# 建立驗證和測試資料集,使用與訓練資料集相同的最大長度
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)

內容解密:

  1. SpamDataset 類別的初始化:載入 CSV 檔案中的資料,並對文字資料進行編碼。
  2. max_length 的確定:如果未指定 max_length,則透過 _longest_encoded_length 方法自動計算最長序列的長度。
  3. 序列的截斷和填充:將所有序列截斷或填充到 max_length,確保批次處理的一致性。
  4. __getitem__ 方法:傳回指定索引的編碼文字和對應的標籤。
  5. DataLoader 的建立:利用 SpamDataset 例項建立訓練、驗證和測試資料載入器,以便於後續的模型訓練和評估。

資料載入器的建立與使用

有了 SpamDataset 類別後,我們可以方便地建立資料載入器。資料載入器負責將資料分批載入記憶體,供模型訓練使用。

from torch.utils.data import DataLoader

# 建立 DataLoader
train_loader = DataLoader(train_dataset, batch_size=8, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=8, shuffle=False)
test_loader = DataLoader(test_dataset, batch_size=8, shuffle=False)

內容解密:

  1. DataLoader 的引數:指定 batch_size 控制每個批次的大小,shuffle 引數用於決定是否在每個 epoch 開始時打亂資料順序。
  2. 訓練資料載入器:通常設定 shuffle=True 以隨機打亂訓練資料,提高模型的泛化能力。
  3. 驗證和測試資料載入器:設定 shuffle=False,因為驗證和測試不需要打亂資料順序。