在本章中,我們將學習如何配置一個預訓練模型以進行文字分類,並如何微調它以適應任何文字分類的下游任務,例如情感分析、多類分類或多標籤分類。我們還將討論如何處理句子對和迴歸問題,並提供一個實現示例。我們將使用如 GLUE 等知名資料集以及我們自己的自定義資料集。隨後,我們將利用 Trainer 類來處理訓練和微調過程中的複雜性。
首先,我們將學習如何使用 Trainer 類微調單句二分類情感分析模型。然後,我們將使用原生 PyTorch 進行情感分類訓練,而不使用 Trainer 類。在多類分類和多標籤分類中,我們將考慮兩個以上的類別。我們將進行七個類的分類微調任務。接下來,我們將訓練一個文字迴歸模型,以預測帶有句子對的數值。
本章將涵蓋以下主題:
文字分類簡介
使用 Trainer 類微調 BERT 模型進行單句二分類
使用原生 PyTorch 訓練分類模型
使用自定義資料集微調 BERT 進行多類分類
微調 BERT 進行句子對迴歸
多標籤文字分類
利用 run_glue.py 微調模型
技術要求
我們將使用 Jupyter Notebook 來執行編碼練習。你需要 Python 3.6 或更高版本,並確保安裝以下包:
sklearn
transformers 4.0+
datasets
文字分類簡介
文字分類(也稱為文字歸類)是一種將文件(如句子、推文、書籍章節、電子郵件內容等)對映到預定義類別列表(類)中的方法。當類別只有兩個且標記為正面和負面時,我們使用二分類——更具體地說,就是情感分析。對於兩個以上的類別,我們稱之為多類分類,其中類別是互斥的;或者是多標籤分類,其中類別不是互斥的,這意味著一個文件可以有多個標籤。例如,一篇新聞文章的內容可能同時涉及體育和政治。除此之外,我們可能還希望對文件進行評分,範圍在 [-1,1] 之間,或在 [1-5] 之間進行排名。這類問題可以透過迴歸模型來解決,其中輸出型別為數值型,而不是類別型。
幸運的是,變換器架構使我們能夠高效地解決這些問題。對於句子對任務,如文件相似度或文字蘊含,輸入不是單個句子,而是兩個句子,如下圖所示。我們可以評估兩個句子在語義上的相似程度或預測它們是否在語義上相似。另一個句子對任務是文字蘊含,其中問題被定義為多類分類。在這裏,兩個序列被對映為 GLUE 基準中的包含/矛盾/中立:
微調 BERT 模型進行單句二分類
在本節中,我們將討論如何微調預訓練的 BERT 模型以進行情感分析,使用的將是流行的 IMDb 情感資料集。使用 GPU 可以加快學習過程,但如果沒有這樣的資源,也可以使用 CPU 進行微調。讓我們開始吧。
要了解並儲存當前裝置,我們可以執行以下程式碼:
from torch import cuda device = 'cuda' if cuda.is_available() else 'cpu'
我們將在這裏使用 DistilBertForSequenceClassification
類,它繼承自 DistilBert
類,並在頂部有一個特殊的序列分類頭。我們可以利用這個分類頭來訓練分類模型,預設情況下類別數為 2:
from transformers import ( DistilBertTokenizerFast, DistilBertForSequenceClassification) model_path = 'distilbert-base-uncased' tokenizer = DistilBertTokenizerFast.from_pretrained(model_path) model = DistilBertForSequenceClassification.from_pretrained( model_path, id2label={0: "NEG", 1: "POS"}, label2id={"NEG": 0, "POS": 1})
注意,我們傳遞了兩個引數 id2label
和 label2id
給模型,用於推理時。或者,我們可以例項化一個特定的配置物件並將其傳遞給模型,如下所示:
config = AutoConfig.from_pretrained(....) SequenceClassification.from_pretrained(.... config=config)
現在,讓我們選擇一個流行的情感分類資料集,稱為 IMDb 資料集。原始資料集包含兩個資料集:25,000 個訓練樣本和 25,000 個測試樣本。我們將資料集拆分為測試集和驗證集。注意,資料集的前一半樣本是正面的,而後一半樣本都是負面的。我們可以如下分配樣本:
from datasets import load_dataset imdb_train = load_dataset('imdb', split="train") imdb_test = load_dataset('imdb', split="test[:6250]+test[-6250:]") imdb_val = load_dataset('imdb', split="test[6250:12500]+test[-12500:-6250]")
讓我們檢查資料集的形狀:
imdb_train.shape, imdb_test.shape, imdb_val.shape # 輸出: ((25000, 2), (12500, 2), (12500, 2))
根據你的計算資源,你可以選擇資料集的一小部分。對於較小的部分,你可以執行以下程式碼,選擇 4,000 個樣本用於訓練,1,000 個用於測試,1,000 個用於驗證:
imdb_train = load_dataset('imdb', split="train[:2000]+train[-2000:]") imdb_test = load_dataset('imdb', split="test[:500]+test[-500:]") imdb_val = load_dataset('imdb', split="test[500:1000]+test[-1000:-500]")
現在,我們可以透過 tokenizer 模型將這些資料集進行處理,使其準備好進行訓練:
enc_train = imdb_train.map(lambda e: tokenizer(e['text'], padding=True, truncation=True), batched=True, batch_size=1000) enc_test = imdb_test.map(lambda e: tokenizer(e['text'], padding=True, truncation=True), batched=True, batch_size=1000) enc_val = imdb_val.map(lambda e: tokenizer(e['text'], padding=True, truncation=True), batched=True, batch_size=1000)
讓我們看看訓練集的樣子。tokenizer 已經將注意力掩碼和輸入 ID 新增到資料集中,以便 BERT 模型可以處理它們:
import pandas as pd pd.DataFrame(enc_train)
輸出結果如下:
此時,資料集已經準備好進行訓練和測試。Trainer
類(對於 TensorFlow 是 TFTrainer
)和 TrainingArguments
類(對於 TensorFlow 是 TFTrainingArguments
)將幫助我們處理大部分訓練的複雜性。我們將在 TrainingArguments
類中定義我們的引數集,然後將其傳遞給 Trainer
物件。
讓我們定義每個訓練引數的作用:
有關更多資訊,請檢視 TrainingArguments
的 API 文件或在 Python Notebook 中執行以下程式碼:
TrainingArguments?
雖然像 LSTM 這樣的深度學習架構需要許多 epochs(有時超過 50 個),但對於基於 Transformer 的微調,我們通常會滿足於 3 個 epochs,因為遷移學習的原因。大多數情況下,這個數字足夠用於微調,因為預訓練模型在預訓練階段已經學到了很多關於語言的知識,預訓練階段通常需要約 50 個 epochs。爲了確定正確的 epochs 數量,我們需要監控訓練和評估的損失。我們將在第 11 章學習如何跟蹤訓練過程。
對於許多下游任務問題,這將足夠,如我們將在這裏看到的那樣。在訓練過程中,我們的模型檢查點將每 200 步儲存到 ./MyIMDBModel
資料夾下:
from transformers import TrainingArguments, Trainer training_args = TrainingArguments( output_dir='./MyIMDBModel', do_train=True, do_eval=True, num_train_epochs=3, per_device_train_batch_size=32, per_device_eval_batch_size=64, warmup_steps=100, weight_decay=0.01, logging_strategy='steps', logging_dir='./logs', logging_steps=200, evaluation_strategy='steps', fp16=cuda.is_available(), load_best_model_at_end=True )
在例項化 Trainer
物件之前,我們將定義 compute_metrics()
方法,這有助於我們根據特定的指標(例如精度、RMSE、皮爾遜相關係數、BLEU 等)監控訓練進度。文字分類問題(如情感分類和多類分類)通常透過微平均或宏平均 F1 分數進行評估。宏平均方法對每個類別給予相等的權重,而微平均則對每個文字或每個標記的分類決策給予相等的權重。微平均等於模型正確決策的次數與總決策次數的比率。另一方面,宏平均方法計算每個類別的精度、召回率和 F1 分數的平均值。對於我們的分類問題,宏平均更方便用於評估,因為我們希望對每個標籤給予相等的權重,如下所示:
from sklearn.metrics import accuracy_score, precision_recall_fscore_support def compute_metrics(pred): labels = pred.label_ids preds = pred.predictions.argmax(-1) precision, recall, f1, _ = precision_recall_fscore_support(labels, preds, average='macro') acc = accuracy_score(labels, preds) return { 'Accuracy': acc, 'F1': f1, 'Precision': precision, 'Recall': recall }
我們幾乎準備好開始訓練過程了。現在,讓我們例項化 Trainer
物件並啟動它。由於 transformers 庫的支援,Trainer
類是一個非常強大且最佳化的工具,用於組織複雜的 PyTorch 和 TensorFlow(TFTrainer 對於 TensorFlow)訓練和評估過程:
trainer = Trainer( model=model, args=training_args, train_dataset=enc_train, eval_dataset=enc_val, compute_metrics=compute_metrics )
最後,我們可以開始訓練過程:
results = trainer.train()
上述呼叫將開始記錄指標,我們將在第 11 章中詳細討論這些指標。整個 IMDb 資料集包括 25,000 個訓練示例。批次大小為 32 時,我們有 25K/32 ≈ 782 步,需要 2,346 步(782 x 3)來完成 3 個 epochs,如下進度條所示:
Trainer
物件在訓練結束時會儲存驗證損失最小的檢查點。它選擇了第 1,400 步的檢查點,因為此步的驗證損失最小。讓我們在三個資料集(訓練集/驗證集/測試集)上評估最佳檢查點:
q = [trainer.evaluate(eval_dataset=data) for data in [enc_train, enc_val, enc_test]] pd.DataFrame(q, index=["train", "val", "test"]).iloc[:, :5]
輸出如下:
幹得好!我們已經成功完成了訓練/測試階段,並在宏平均下獲得了 92.6 的準確率和 F1 分數。要更詳細地監控訓練過程,可以使用高階工具如 TensorBoard。這些工具可以解析日誌,並讓我們跟蹤各種指標以進行全面分析。我們已經在 ./logs
資料夾中記錄了效能和其他指標。只需在 Python notebook 中執行以下 tensorboard
函式即可,如下所示(我們將在第 11 章詳細討論 TensorBoard 和其他監控工具):
%reload_ext tensorboard %tensorboard --logdir logs
現在,我們將使用模型進行推理,以檢查它是否正常工作。讓我們定義一個預測函式以簡化預測步驟,如下所示:
def get_prediction(text): inputs = tokenizer(text, padding=True, truncation=True, max_length=250, return_tensors="pt").to(device) outputs = model(inputs["input_ids"].to(device), inputs["attention_mask"].to(device)) probs = outputs[0].softmax(1) return probs, probs.argmax()
現在,執行模型進行推理:
text = "I didn't like the movie it bored me " get_prediction(text)[1].item()
我們得到的結果是 0,表示負面情感。我們已經定義了哪個 ID 對應哪個標籤。我們可以使用這種對映方案來獲取標籤。或者,我們可以將所有這些繁瑣的步驟直接傳遞給專用的 API,即 Pipeline,我們已經很熟悉了。在例項化之前,讓我們儲存最佳模型以備後續推理使用:
model_save_path = "MyBestIMDBModel" trainer.save_model(model_save_path) tokenizer.save_pretrained(model_save_path)
Pipeline API 是使用預訓練模型進行推理的一種簡單方法。我們從儲存位置載入模型並將其傳遞給 Pipeline API,後者會處理其餘部分。我們可以跳過這一步儲存過程,直接將記憶體中的模型和 tokenizer 物件傳遞給 Pipeline API。如果這樣做,你會得到相同的結果。
如下程式碼所示,當我們執行二分類任務時,需要指定 Pipeline 的任務名稱引數為 sentiment-analysis:
from transformers import ( pipeline, DistilBertForSequenceClassification, DistilBertTokenizerFast) model = DistilBertForSequenceClassification.from_pretrained("MyBestIMDBModel") tokenizer = DistilBertTokenizerFast.from_pretrained("MyBestIMDBModel") nlp = pipeline("sentiment-analysis", model=model, tokenizer=tokenizer)
測試模型:
nlp("the movie was very impressive")
輸出:
[{'label': 'POS', 'score': 0.9621992707252502}]
再測試一次:
nlp("the text of the picture was very poor")
輸出:
[{'label': 'NEG', 'score': 0.9938313961029053}]
Pipeline API 知道如何處理輸入,並且以某種方式學習了哪個 ID 對應哪個標籤(POS 或 NEG)。它還提供了類別機率。
幹得好!我們已經使用 Trainer 類為 IMDb 資料集微調了情感預測模型。在下一節中,我們將使用原生 PyTorch 進行相同的二分類訓練,並使用不同的資料集。
使用原生 PyTorch 訓練分類模型
Trainer
類非常強大,我們要感謝 Hugging Face 團隊提供了這樣一個有用的工具。然而,在本節中,我們將從頭開始微調預訓練模型,以瞭解其內部工作原理。讓我們開始吧。
首先,載入用於微調的模型。我們將選擇 DistilBert,因為它是 BERT 的一個小型、快速且廉價的版本:
from transformers import DistilBertForSequenceClassification model = DistilBertForSequenceClassification.from_pretrained('distilbert-base-uncased')
爲了微調任何模型,我們需要將其置於訓練模式,如下所示:
model.train()
接下來,我們需要載入 tokenizer:
from transformers import DistilBertTokenizerFast tokenizer = DistilBertTokenizerFast.from_pretrained('bert-base-uncased')
由於 Trainer
類為我們組織了整個過程,我們在之前的 IMDb 情感分類練習中沒有涉及最佳化和其他訓練設定。現在,我們需要自己例項化最佳化器。在這裏,我們必須選擇 AdamW,它是 Adam 演算法的一個實現,但修正了權重衰減問題。最近的研究表明,AdamW 在訓練損失和驗證損失上表現得比 Adam 更好。因此,它在許多 transformer 訓練過程中被廣泛使用:
from transformers import AdamW optimizer = AdamW(model.parameters(), lr=1e-3)
爲了從頭設計微調過程,我們必須瞭解如何實現單步的前向傳播和反向傳播。我們可以將一個批次的資料透過 transformer 層,得到輸出,這稱為前向傳播。然後,我們必須使用輸出和實際標籤計算損失,並根據損失更新模型權重,這稱為反向傳播。
以下程式碼接收與標籤相關聯的三句話,進行一次批次的前向傳播。最後,模型會自動計算損失:
import torch texts = ["this is a good example", "this is a bad example", "this is a good one"] labels = [1, 0, 1] labels = torch.tensor(labels).unsqueeze(0) encoding = tokenizer(texts, return_tensors='pt', padding=True, truncation=True, max_length=512) input_ids = encoding['input_ids'] attention_mask = encoding['attention_mask'] outputs = model(input_ids, attention_mask=attention_mask, labels=labels) loss = outputs.loss loss.backward() optimizer.step()
輸出如下:
SequenceClassifierOutput( [('loss', tensor(0.7178, grad_fn=<NllLossBackward>)), ('logits', tensor([[ 0.0664, -0.0161], [ 0.0738, 0.0665], [ 0.0690, -0.0010]], grad_fn=<AddmmBackward>))])
模型接收 input_ids
和 attention_mask
,這些是由 tokenizer 生成的,並使用實際標籤計算損失。我們可以看到輸出包括損失和 logits。loss.backward()
計算了張量的梯度,透過輸入和標籤對模型進行評估。optimizer.step()
執行單步最佳化,並使用計算出的梯度更新權重,這被稱為反向傳播。當我們將這些步驟放入一個迴圈時,我們還將新增 optimizer.zero_grad()
,它清除所有引數的梯度。重要的是在迴圈開始時呼叫這個方法,否則我們可能會積累來自多個步驟的梯度。輸出中的第二個張量是 logits。在深度學習中,logits 是神經網路的最後一層,包括預測值(實數)。在分類的情況下,logits 需要透過 softmax 函式轉換為機率。否則,它們僅僅是用於迴歸的標準化值。
如果我們要手動計算損失,我們不能將標籤傳遞給模型。因此,模型只會產生 logits,而不會計算損失。在以下示例中,我們手動計算了交叉熵損失:
from torch.nn import functional labels = torch.tensor([1, 0, 1]) outputs = model(input_ids, attention_mask=attention_mask) loss = functional.cross_entropy(outputs.logits, labels) loss.backward() optimizer.step()
輸出:
tensor(0.6101, grad_fn=<NllLossBackward>)
透過以上操作,我們瞭解瞭如何在前向傳播過程中處理批次輸入。現在,是時候設計一個迴圈,以批次的形式遍歷整個資料集,從而訓練模型多個週期。為此,我們將從設計 Dataset
類開始。它是 torch.Dataset
的一個子類,繼承了成員變數和函式,並實現了 __init__()
和 __getitem__()
抽象函式:
from torch.utils.data import Dataset class MyDataset(Dataset): def __init__(self, encodings, labels): self.encodings = encodings self.labels = labels def __getitem__(self, idx): item = {key: torch.tensor(val[idx]) for key, val in self.encodings.items()} item['labels'] = torch.tensor(self.labels[idx]) return item def __len__(self): return len(self.labels)
我們將使用一個名為 Stanford Sentiment Treebank v2 (sst2) 的情感分析資料集來微調模型。我們還將載入 sst2 對應的評估指標,如下所示:
import datasets from datasets import load_dataset sst2 = load_dataset("glue", "sst2") from datasets import load_metric metric = load_metric("glue", "sst2")
我們將提取句子和標籤:
texts = sst2['train']['sentence'] labels = sst2['train']['label'] val_texts = sst2['validation']['sentence'] val_labels = sst2['validation']['label']
現在,我們可以透過 tokenizer 處理資料集,並例項化 MyDataset
物件,使 BERT 模型可以使用它們:
train_dataset = MyDataset(tokenizer(texts, truncation=True, padding=True), labels) val_dataset = MyDataset(tokenizer(val_texts, truncation=True, padding=True), val_labels)
讓我們例項化 DataLoader
類,它提供了一個介面,透過載入順序迭代資料樣本。這也有助於批處理和記憶體固定:
from torch.utils.data import DataLoader train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=16, shuffle=True)
以下程式碼檢測裝置並正確定義 AdamW 最佳化器:
from transformers import AdamW device = torch.device('cuda') if torch.cuda.is_available() else torch.device('cpu') model.to(device) optimizer = AdamW(model.parameters(), lr=1e-3)
到目前為止,我們已經瞭解瞭如何實現前向傳播,這是在一個批次的示例中處理資料的過程。在一個步驟中,每一層從第一層到最後一層都透過批次資料進行處理,經過啟用函式後傳遞給下一層。爲了透過多個週期遍歷整個資料集,我們設計了兩個巢狀迴圈:外部迴圈用於 epoch,內部迴圈用於每個批次的步驟。內部部分由兩個塊組成,一個是訓練,另一個是評估每個 epoch。正如你所注意到的,我們在第一個訓練迴圈中呼叫了 model.train()
,而在第二個評估塊中,我們呼叫了 model.eval()
。這很重要,因為我們將模型置於訓練和推理模式。
我們已經討論了內部塊。請注意,我們透過對應的 metric 物件跟蹤模型的效能:
for epoch in range(3): model.train() for batch in train_loader: optimizer.zero_grad() input_ids = batch['input_ids'].to(device) attention_mask = batch['attention_mask'].to(device) labels = batch['labels'].to(device) outputs = model(input_ids, attention_mask=attention_mask, labels=labels) loss = outputs[0] loss.backward() optimizer.step() model.eval() for batch in val_loader: input_ids = batch['input_ids'].to(device) attention_mask = batch['attention_mask'].to(device) labels = batch['labels'].to(device) outputs = model(input_ids, attention_mask=attention_mask, labels=labels) predictions = outputs.logits.argmax(dim=-1) metric.add_batch(predictions=predictions, references=batch["labels"]) eval_metric = metric.compute() print(f"epoch {epoch}: {eval_metric}")
輸出:
epoch 0: {'accuracy': 0.9048165137614679} epoch 1: {'accuracy': 0.8944954128440367} epoch 2: {'accuracy': 0.9094036697247706}
幹得好!我們已經微調了模型,並獲得了約 90.94 的準確率。剩下的過程,例如儲存、載入和推理,將類似於我們在 Trainer
類中所做的。
到此為止,我們已經完成了二分類任務。在下一節中,我們將學習如何為非英語語言實現多類分類模型。
使用自定義資料集微調 BERT 進行多類分類
在本節中,我們將微調土耳其語 BERT,即 BERTurk,以執行七類分類的下游任務,資料集來自土耳其報紙,包含七個類別。我們將從獲取資料集開始。你也可以在本書的 GitHub 倉庫中找到這個資料集,或者從 Kaggle 下載。
首先,在 Python Notebook 中執行以下程式碼以獲取資料:
!wget https://raw.githubusercontent.com/savasy/TurkishTextClassification/master/TTC4900.csv
然後,我們載入資料:
import pandas as pd data = pd.read_csv("TTC4900.csv") data = data.sample(frac=1.0, random_state=42)
接下來,我們將使用 id2label
和 label2id
來組織 ID 和標籤,以便模型能夠識別每個 ID 對應的標籤。我們還將傳遞標籤數量 NUM_LABELS
給模型,以指定 BERT 模型頂部的薄分類頭層的大小:
labels = ["teknoloji", "ekonomi", "saglik", "siyaset", "kultur", "spor", "dunya"] NUM_LABELS = len(labels) id2label = {i: l for i, l in enumerate(labels)} label2id = {l: i for i, l in enumerate(labels)} data["labels"] = data.category.map(lambda x: label2id[x.strip()]) data.head()
輸出結果如下:
讓我們使用 pandas 物件計算並繪製類別數量的餅圖:
data.category.value_counts().plot(kind='pie')
如下面的圖示所示,資料集的類別分佈相對均勻:
以下程式碼例項化了一個序列分類模型,設定了標籤數量(7)、標籤 ID 對映以及 BERTurk 模型:
>>> model
輸出將是模型的總結,內容較長,此處不一一展示。我們將重點關注最後一層:
(classifier): Linear(in_features=768, out_features=7, bias=True)
你可能注意到,我們沒有選擇 DistilBert,因為沒有為土耳其語預訓練的無大寫 DistilBert 模型:
from transformers import BertTokenizerFast tokenizer = BertTokenizerFast.from_pretrained("dbmdz/bert-base-turkish-uncased", max_length=512) from transformers import BertForSequenceClassification model = BertForSequenceClassification.from_pretrained( "dbmdz/bert-base-turkish-uncased", num_labels=NUM_LABELS, id2label=id2label, label2id=label2id ) model.to(device)
接下來,讓我們準備訓練集(50%)、驗證集(25%)和測試集(25%),如下所示:
SIZE = data.shape[0] # 句子 train_texts = list(data.text[:SIZE//2]) val_texts = list(data.text[SIZE//2:(3*SIZE)//4]) test_texts = list(data.text[(3*SIZE)//4:]) # 標籤 train_labels = list(data.labels[:SIZE//2]) val_labels = list(data.labels[SIZE//2:(3*SIZE)//4]) test_labels = list(data.labels[(3*SIZE)//4:]) # 檢查大小 len(train_texts), len(val_texts), len(test_texts) # 輸出: (2450, 1225, 1225)
以下程式碼將三個資料集的句子進行分詞,並將其轉換為整數(input_ids),然後輸入到 BERT 模型中:
train_encodings = tokenizer(train_texts, truncation=True, padding=True) val_encodings = tokenizer(val_texts, truncation=True, padding=True) test_encodings = tokenizer(test_texts, truncation=True, padding=True)
我們已經實現了 MyDataset
類。該類繼承自抽象的 Dataset
類,並重寫了 __getitem__
和 __len__()
方法,分別用於返回資料項和資料集的大小:
train_dataset = MyDataset(train_encodings, train_labels) val_dataset = MyDataset(val_encodings, val_labels) test_dataset = MyDataset(test_encodings, test_labels)
由於資料集相對較小,我們將批次大小保持為 16。請注意,TrainingArguments
的其他引數與之前的情感分析實驗幾乎相同:
from transformers import TrainingArguments, Trainer training_args = TrainingArguments( output_dir='./TTC4900Model', do_train=True, do_eval=True, num_train_epochs=3, per_device_train_batch_size=16, per_device_eval_batch_size=32, warmup_steps=100, weight_decay=0.01, logging_strategy='steps', logging_dir='./multi-class-logs', logging_steps=50, evaluation_strategy="epoch", eval_steps=50, save_strategy="epoch", fp16=True, load_best_model_at_end=True )
情感分析和文字分類使用相同的評估指標,即宏平均 F1、精確度和召回率。因此,我們不再重新定義 compute_metrics()
函式。下面是例項化 Trainer
物件的程式碼:
trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=val_dataset, compute_metrics=compute_metrics )
最後,讓我們開始訓練過程:
trainer.train()
輸出結果如下:
要檢查訓練後的模型,我們需要在三個資料集劃分上評估微調後的模型,具體如下。我們最佳的模型在第 300 步時微調完成,損失為 0.28012:
q = [trainer.evaluate(eval_dataset=data) for data in [train_dataset, val_dataset, test_dataset]] pd.DataFrame(q, index=["train", "val", "test"]).iloc[:, :5]
輸出結果如下:
分類準確率約為 92.6%,而 F1 宏平均約為 92.5%。在文獻中,許多方法已在這個土耳其基準資料集上進行了測試。這些方法主要採用 TF-IDF 和線性分類器、word2vec 嵌入或基於 LSTM 的分類器,最好的 F1 值也僅約為 90.0。與這些方法相比,除了 Transformer 模型外,微調後的 BERT 模型表現更為優越。
與其他實驗一樣,我們可以透過 TensorBoard 跟蹤實驗程序:
%load_ext tensorboard %tensorboard --logdir multi-class-logs/
讓我們設計一個函式來執行模型進行推斷。如果你想看到實際的標籤而不是 ID,可以使用模型的配置物件,如下所示的預測函式:
def predict(text): inputs = tokenizer(text, padding=True, truncation=True, max_length=512, return_tensors="pt").to("cuda") outputs = model(**inputs) probs = outputs[0].softmax(1) return (probs, probs.argmax(), model.config.id2label[probs.argmax().item()])
現在,我們準備呼叫預測函式進行文字分類推斷。以下程式碼對關於足球隊的句子進行分類:
text = "Fenerbahçeli futbolcular kısa paslarla hazırlık çalışması yaptılar" predict(text)
輸出:
(tensor([[5.6183e-04, 4.9046e-04, 5.1385e-04, 9.94e-04, 3.44e-04, 9.96e-01, 4.061e-04]], device='cuda:0', grad_fn=<SoftmaxBackward>), tensor(5, device='cuda:0'), 'spor')
如我們所見,模型正確地將句子預測為體育(spor)。現在,是時候儲存模型並使用 from_pre-trained()
函式重新載入它了。以下是程式碼:
model_path = "turkish-text-classification-model" trainer.save_model(model_path) tokenizer.save_pretrained(model_path)
現在,我們可以重新載入儲存的模型,並使用 pipeline
類進行推斷:
model_path = "turkish-text-classification-model" from transformers import (pipeline, BertForSequenceClassification, BertTokenizerFast) model = BertForSequenceClassification.from_pretrained(model_path) tokenizer = BertTokenizerFast.from_pretrained(model_path) nlp = pipeline("sentiment-analysis", model=model, tokenizer=tokenizer)
你可能注意到任務名稱是 sentiment-analysis
。這個術語可能會讓人困惑,但這個引數最終會返回 TextClassificationPipeline
。讓我們執行管道:
nlp("Sinemada hangi filmler oynuyor bugün")
輸出:
[{'label': 'kultur', 'score': 0.9930670261383057}]
nlp("Dolar ve Euro bugün yurtiçi piyasalarda yükseldi")
輸出:
[{'label': 'ekonomi', 'score': 0.9927696585655212}]
nlp("Bayern Münih ile Barcelona bugün karşı karşıya geliyor. \ Maçı İngiliz hakem James Watts yönetecek!")
輸出:
[{'label': 'spor', 'score': 0.9975664019584656}]
模型已經成功預測。
到目前為止,我們已經實現了兩個單句任務——情感分析和多類別分類。在下一節中,我們將學習如何處理句子對輸入以及如何設計 BERT 的迴歸模型。
微調 BERT 模型以進行句子對迴歸
迴歸模型通常用於分類任務,但在這種情況下,最後一層僅包含一個單元。與透過 softmax 邏輯迴歸處理不同,它會被歸一化。要定義模型並在頂部新增一個單單元頭層,有兩個選項:直接在 BERT.from_pretrained()
方法中包括 num_labels=1
引數,或透過配置物件傳遞此資訊。最初,需要從預訓練模型的配置物件中複製這個配置,如下所示:
from transformers import ( DistilBertConfig, DistilBertTokenizerFast, DistilBertForSequenceClassification) model_path = 'distilbert-base-uncased' config = DistilBertConfig.from_pretrained(model_path, num_labels=1) tokenizer = DistilBertTokenizerFast.from_pretrained(model_path) model = DistilBertForSequenceClassification.from_pretrained( model_path, config=config)
透過 num_labels=1
引數,我們的預訓練模型擁有了一個單單元頭層。現在,我們準備使用我們的資料集來微調模型。我們將使用語義文字相似度基準(STS-B),這是一個包含句子對的資料集,這些句子對來源於各種內容,如新聞標題。每對句子都被標註了一個從 1 到 5 的相似度評分。我們的任務是微調 BERT 模型以預測這些評分。我們將使用 Pearson/Spearman 相關係數來評估模型,遵循文獻中的做法。讓我們開始吧。
以下程式碼載入資料。原始資料被拆分為三部分。然而,測試集沒有標籤,因此我們可以將驗證資料分為兩部分,如下所示:
import datasets from datasets import load_dataset stsb_train = load_dataset('glue', 'stsb', split="train") stsb_validation = load_dataset('glue', 'stsb', split="validation") stsb_validation = stsb_validation.shuffle(seed=42) stsb_val = datasets.Dataset.from_dict(stsb_validation[:750]) stsb_test = datasets.Dataset.from_dict(stsb_validation[750:])
讓我們透過 Pandas 包裝 stsb_train
訓練資料,使其更加整潔:
pd.DataFrame(stsb_train)
這就是訓練資料的樣子:
執行以下程式碼以檢查三個資料集的形狀:
stsb_train.shape, stsb_val.shape, stsb_test.shape
輸出結果是:
((5749, 4), (750, 4), (750, 4))
執行以下程式碼以對資料集進行分詞:
enc_train = stsb_train.map(lambda e: tokenizer( e['sentence1'], e['sentence2'], padding=True, truncation=True), batched=True, batch_size=1000) enc_val = stsb_val.map(lambda e: tokenizer( e['sentence1'], e['sentence2'], padding=True, truncation=True), batched=True, batch_size=1000) enc_test = stsb_test.map(lambda e: tokenizer( e['sentence1'], e['sentence2'], padding=True, truncation=True), batched=True, batch_size=1000)
分詞器將兩個句子用 [SEP]
分隔符合並,並生成一個句子對的 input_ids
和 attention_mask
,如下所示:
pd.DataFrame(enc_train)
輸出結果如下:
與其他實驗類似,我們在 TrainingArguments
和 Trainer
類中的配置幾乎相同。以下是程式碼:
from transformers import TrainingArguments, Trainer training_args = TrainingArguments( output_dir='./stsb-model', do_train=True, do_eval=True, num_train_epochs=3, per_device_train_batch_size=32, per_device_eval_batch_size=64, warmup_steps=100, weight_decay=0.01, logging_strategy='steps', logging_dir='./logs', logging_steps=50, evaluation_strategy="steps", save_strategy="epoch", fp16=True, load_best_model_at_end=True )
當前迴歸任務與之前的分類任務之間的另一個重要區別是 compute_metrics
的設計。在這裏,我們的評估指標將基於 Pearson 相關係數和 Spearman 秩相關係數,這符合文獻中的常見做法。我們還提供了均方誤差(MSE)、均方根誤差(RMSE)和平均絕對誤差(MAE)指標,這些指標通常用於迴歸模型:
import numpy as np from scipy.stats import pearsonr from scipy.stats import spearmanr def compute_metrics(pred): preds = np.squeeze(pred.predictions) return { "MSE": ((preds - pred.label_ids) ** 2).mean().item(), "RMSE": np.sqrt(((preds - pred.label_ids) ** 2).mean()).item(), "MAE": np.abs(preds - pred.label_ids).mean().item(), "Pearson": pearsonr(preds, pred.label_ids)[0], "Spearman": spearmanr(preds, pred.label_ids)[0] }現在,讓我們例項化 Trainer 物件:
trainer = Trainer( model=model, args=training_args, train_dataset=enc_train, eval_dataset=enc_val, compute_metrics=compute_metrics, tokenizer=tokenizer )
然後,執行訓練:
train_result = trainer.train()
輸出結果如下:
最佳的驗證損失是在第 450 步計算得出的,為 0.544973。讓我們對在該步驟獲得的最佳檢查點模型進行評估,程式碼如下:
q = [trainer.evaluate(eval_dataset=data) for data in [enc_train, enc_val, enc_test]] pd.DataFrame(q, index=["train", "val", "test"]).iloc[:, :5]
輸出結果如下:
皮爾遜和斯皮爾曼相關係數在測試資料集上的得分分別為 87.54 和 87.28。雖然我們沒有獲得最先進的結果,但根據 GLUE 基準排行榜,我們得到了一個可比的 STS-B 任務結果。
現在我們準備進行模型推理。讓我們使用以下兩個含義相同的句子,並將其傳遞給模型:
s1, s2 = "A plane is taking off.", "An air plane is taking off." encoding = tokenizer(s1, s2, return_tensors='pt', padding=True, truncation=True, max_length=512) input_ids = encoding['input_ids'].to(device) attention_mask = encoding['attention_mask'].to(device) outputs = model(input_ids, attention_mask=attention_mask) outputs.logits.item()
輸出結果為:4.033723831176758
接下來,我們處理負面的句子對,這意味著這些句子在語義上是不同的:
s1, s2 = "The men are playing soccer.", "A man is riding a motorcycle." encoding = tokenizer("hey how are you there", "hey how are you", return_tensors='pt', padding=True, truncation=True, max_length=512) input_ids = encoding['input_ids'].to(device) attention_mask = encoding['attention_mask'].to(device) outputs = model(input_ids, attention_mask=attention_mask) outputs.logits.item()
輸出結果為:2.3579328060150146
最後,我們將模型儲存起來,程式碼如下:
model_path = "sentence-pair-regression-model" trainer.save_model(model_path) tokenizer.save_pretrained(model_path)
幹得好!我們已經成功完成了三個任務:情感分析、多類分類和句子對迴歸。所有這些任務都基於單標籤。接下來,我們將學習如何解決多標籤分類問題。
多標籤文字分類
在本章中,我們已經解決了多類別文字分類問題,其中每個文字只分配一個標籤。現在,我們將討論多標籤分類,其中一個文字可以有多個標籤。這在自然語言處理應用中很常見,例如新聞分類。例如,一條新聞可能同時與體育和健康相關。下圖展示了多標籤分類的示例:
現在,我們將深入探討如何開發一個多標籤分類的流程。為此,我們將使用PubMed資料集,該資料集包含約50,000篇研究文章。這些文章被生物醫學專家手動標註了MeSH標籤,每篇文章根據14個MeSH標籤的組合進行描述。
首先,我們需要匯入相關庫:
import torch import numpy as np import pandas as pd from datasets import Dataset, load_dataset from transformers import (AutoTokenizer, AutoModelForSequenceClassification, TrainingArguments, Trainer)
資料集已經由Hugging Face Hub提供託管。我們從Hub下載資料集,如下所示。爲了快速訓練,我們僅選擇訓練資料集的10%。如果你希望獲得更好的效能,可以使用整個資料集:
path = "owaiskha9654/PubMed_MultiLabel_Text_Classification_Dataset_MeSH" dataset = load_dataset(path, split="train[:10%]") train_dataset = pd.DataFrame(dataset) text_column = 'abstractText' # 文字欄位 label_names = list(train_dataset.columns[6:]) num_labels = len(label_names) print('Number of Labels:', num_labels) train_dataset[[text_column] + label_names]
輸出結果如下:
Number of Labels: 14
如下面的表格所示,我們有一個abstractText
欄位和14個可能的標籤,其中1和0分別表示標籤的存在和不存在。
現在,讓我們計算標籤分佈並進行分析,使用以下程式碼:
train_dataset[label_names].apply(lambda x: sum(x), axis=0).plot(kind="bar", figsize=(10, 6))
這將繪製以下圖表:
從不平衡的分佈中可以看出,我們有一些較小的標籤,如 F、H、I、J、L 和 Z。對整個資料集進行分析時,也會得到類似的分佈!
我們需要將標籤列轉換為一個列表,如下所示:
train_dataset["labels"] = train_dataset.apply( lambda x: x[label_names].to_numpy(), axis=1) train_dataset[[text_column, "labels"]]
現在,我們得到了以下形狀(abstractText -> labels),這是後續管道所需要的:
我們將微調 distilbert-base-uncased
模型。由於需要對文字進行標記化,我們首先載入 DistilBert 分詞器,如下所示:
model_path = "distilbert-base-uncased" tokenizer = AutoTokenizer.from_pretrained(model_path) def tokenize(batch): return tokenizer(batch[text_column], padding=True, truncation=True)
現在,我們將資料集拆分為三個子集(50% 訓練集、25% 驗證集和 25% 測試集),並相應地對其進行標記化:
q = train_dataset[[text_column, "labels"]].copy() CUT = int((q.shape)[0] * 0.5) CUT2 = int((q.shape)[0] * 0.75) train_df = q[:CUT] # 訓練集 val_df = q[CUT:CUT2] # 驗證集 test_df = q[CUT2:] # 測試集 train = Dataset.from_pandas(train_df) # 轉換為 Dataset 物件 val = Dataset.from_pandas(val_df) test = Dataset.from_pandas(test_df) train_encoded = train.map(tokenize, batched=True, batch_size=None) val_encoded = val.map(tokenize, batched=True, batch_size=None) test_encoded = test.map(tokenize, batched=True, batch_size=None)
我們現在有三個子集的資料集,這些資料集已經透過分詞器進行了編碼,並準備好由 Transformer 模型處理。
接下來,我們將定義一個函式來處理 Transformer 模型最後一層的啟用(logits),以生成預測向量。如果我們進行的是單標籤分類,softmax 將是最合適的公式,因為我們有一個輸出。然而,由於許多標籤可以是正確的標籤且它們不是互斥的,我們將透過 sigmoid 函式獨立地傳遞所有標籤,而不是使用 softmax。透過這樣做,我們將有機會同時選擇所有標籤作為預測或沒有任何標籤。我們透過將 sigmoid 函式的輸出透過簡單的 >0.5 閾值來做出決定。
現在,我們將定義 compute_metrics()
函式,以在訓練期間監控模型效能。以下函式利用 sklearn 庫給出標籤存在的精確度、召回率和 F1 分數。請注意,在 f1_score()
函式中,我們設定 pos_label=1
,因為我們只關注標籤的存在。如果考慮標籤的存在和缺失,你可能會得到人為的高結果,並且很難監控模型效能,尤其是對於標籤稀疏模式!
讓我們定義 compute_metrics
函式:
from sklearn.metrics import (f1_score, precision_score, recall_score) def compute_metrics(eval_pred): y_pred, y_true = eval_pred y_pred = torch.from_numpy(y_pred) y_true = torch.from_numpy(y_true) y_pred = y_pred.sigmoid() > 0.5 y_true = y_true.bool() r = recall_score(y_true, y_pred, average='micro', pos_label=1) p = precision_score(y_true, y_pred, average='micro', pos_label=1) f1 = f1_score(y_true, y_pred, average='micro', pos_label=1) result = {"Recall": r, "Precision": p, "F1": f1} return result
對於單標籤多分類任務,我們使用 softmax 啟用函式加上交叉熵損失函式。然而,對於多標籤模式,我們需要使用不同的啟用函式和損失函式。在實現過程中,我們保留原始 Trainer 的所有其他功能,但必須調整損失函式。具體來說,我們將 Trainer 類原始損失函式中的 torch.nn.CrossEntropyLoss()
函式替換為 torch.nn.BCEWithLogitsLoss()
。
如下所示,在 MultilabelTrainer
類定義中,torch.nn.BCEWithLogitsLoss()
函式計算真實標籤和原始 logits 之間的損失。損失函式首先透過 sigmoid 函式生成預測,然後計算損失:
class MultilabelTrainer(Trainer): def compute_loss(self, model, inputs, return_outputs=False): labels = inputs.pop("labels") outputs = model(**inputs) logits = outputs.logits loss_fct = torch.nn.BCEWithLogitsLoss() preds_ = logits.view(-1, self.model.config.num_labels) labels_ = labels.float().view(-1, self.model.config.num_labels) loss = loss_fct(preds_, labels_) return (loss, outputs) if return_outputs else loss
此時,Trainer
例項和 TrainingArguments
例項與之前的多分類模式大致相同。以下是引數:
batch_size = 16 num_epoch = 3 args = TrainingArguments( output_dir="/tmp", per_device_train_batch_size=batch_size, per_device_eval_batch_size=batch_size, num_train_epochs=num_epoch, do_train=True, do_eval=True, load_best_model_at_end=True, save_steps=100, eval_steps=100, save_strategy="steps", evaluation_strategy="steps")
我們將載入 DistilBert 模型。注意,我們將模型的輸出層大小設定為 14(與 num_labels
一致):
model = AutoModelForSequenceClassification.from_pretrained( model_path, num_labels=num_labels)
好了!我們準備開始訓練。開始吧!
multi_trainer = MultilabelTrainer(model, args, train_dataset=train_encoded, eval_dataset=val_encoded, compute_metrics=compute_metrics, tokenizer=tokenizer) multi_trainer.train()
如果一切順利,我們可以在測試資料集上測試我們的模型,如下所示:
res = multi_trainer.predict(test_encoded) pd.Series(compute_metrics(res[:2])).to_frame()
以下是輸出結果:
完成了!我們已經用一個取樣的資料集訓練了一個多標籤分類模型,進行了快速的實驗。請注意,使用完整的資料集進行訓練會得到更好的F1得分!請你自己試試看吧!接下來,我們將使用一個指令碼來簡化整個過程。
利用 run_glue.py
進行模型微調
到目前為止,我們已經使用原生PyTorch和Trainer類從頭設計了一個微調架構。Hugging Face社羣還提供了一個強大的指令碼 run_glue.py
,用於GLUE基準測試和類似GLUE的分類下游任務。這個指令碼可以處理和組織整個訓練/驗證過程。如果你想進行快速原型開發,應該使用這個指令碼。它可以微調任何在Hugging Face Hub上預訓練的模型。我們也可以將自己的資料以任何格式輸入給它。
這個指令碼可以執行九種不同的GLUE任務。使用這個指令碼,我們可以完成到目前為止使用Trainer類所做的所有操作。任務名稱可以是以下GLUE任務中的一種:cola、sst2、mrpc、stsb、qqp、mnli、qnli、rte或wnli。
以下是微調模型的指令碼示例:
export TASK_NAME="My-Task-Name" python run_glue.py \ --model_name_or_path bert-base-cased \ --task_name $TASK_NAME \ --do_train \ --do_eval \ --max_seq_length 128 \ --per_device_train_batch_size 32 \ --learning_rate 2e-5 \ --num_train_epochs 3 \ --output_dir /tmp/$TASK_NAME/
社羣還提供了另一個指令碼 run_glue_no_trainer.py
。與原始指令碼的主要區別是,這個 no_trainer 指令碼給了我們更多的機會來更改最佳化器的選項或新增我們想要的自定義內容。
總結
在這一章中,我們討論瞭如何對任何文字分類下游任務微調預訓練模型。我們使用情感分析、多類別分類和句子對分類(具體來說是句子對迴歸和多標籤分類)來微調模型。我們使用了一個知名的IMDb資料集和我們自己的自定義資料集來訓練模型。雖然我們利用了Trainer類來應對訓練和微調過程中的複雜性,但我們也學習瞭如何使用原生庫從頭開始訓練,以理解transformers庫中的前向傳播和反向傳播。總結來說,我們討論並進行了一些微調工作,包括使用Trainer進行單句分類,使用原生PyTorch進行情感分類,單句多類別分類,多標籤分類,以及句子對迴歸的微調。