自然語言處理 (NLP) 領域的進展日新月異,你方唱罷我登場。因此,在實際場景中,針對特定的任務,我們經常需要對不同的語言模型進行比較,以尋找最適合的模型。本文主要比較 3 個模型: RoBERTa、Mistral-7B 及 Llama-2-7B。我們用它們來解決一個常見問題 —— 對災難相關的推文進行分類。值得注意的是,Mistral 和 Llama 2 是 70 億引數的大模型。相形之下,RoBERTa-large (355M 引數) 只是一個小模型,我們用它作為比較的基線。
本文,我們使用 PEFT (Parameter-Efficient Fine-Tuning,引數高效微調) 技術: LoRA (Low-Rank Adaptation,低秩適配) 來微調帶序列分類任務頭的預訓練模型。LoRA 旨在顯著減少可訓引數量,同時保持強大的下游任務效能。
本文的主要目標是通過對 Hugging Face 的三個預訓練模型進行 LoRA 微調,使之適用於序列分類任務。這三個預訓練模型分別是: meta-llama/Llama-2-7b-hf、mistralai/Mistral-7B-v0.1 及 roberta-large。
datasets
evaluate
peft
scikit-learn
torch
transformers
wandb
注意: 要準確重現本文結果,請注意確保軟體版本與 wandb 報告 的一致。
RoBERTa (Robustly Optimized BERT Approach) 是 Meta AI 研究團隊提出的改進版 BERT 模型。BERT 是一種基於 transformer 的語言模型,其基於自注意力機制對單詞進行上下文感知的表徵,並基於掩碼語言模型目標進行訓練。請注意,BERT 作為編碼器模型,僅可用於自然語言理解任務 (例如序列分類和詞元分類)。
RoBERTa 是一種流行的可微調模型,很適合作為我們實驗的基線。欲瞭解更多資訊,你可以查閱其 Hugging Face 模型卡。
Llama 2 (Large Language Model Meta AI) 是 Meta AI 推出的一系列大語言模型 (LLM),其模型大小各異,引數量從 70 億到 650 億不等。
Llama 2 是一種基於 transformer 解碼器架構的自迴歸語言模型。Llama 2 接受單詞序列作為輸入,並基於滑動視窗迭代預測下一個詞元,從而實現文字生成的功能。
Llama 2 的架構與 GPT-3 等模型略有不同。舉幾個例子,Llama 2 採用 SwiGLU 啟用函數而不是 ReLU,另外其位置嵌入使用的是旋轉位置嵌入而不是可訓絕對位置嵌入。
最近釋出的 Llama 2 還對架構進行了改進,其將支援的最大上下文長度擴充套件到 4096 個詞元,並使用分組查詢注意 (grouped-query attention,GQA) 解碼機制來更好地利用長序列。
Mistral 7B v0.1 有 73 億個引數,是 Mistral AI 推出的第一個 LLM。
Mistral 7B 架構使用的新技術主要有:
PEFT (Parameter Efficient Fine-Tuning,引數高效微調) 包含 p-tuning、字首微調 (prefix-tuning) 、IA3、介面卡微調以及 LoRA 等一系列技術,其旨在通過僅微調大模型的一個小引數集,就能達到全模型微調的效能水平。
LoRA (Low-Rank Adaptation,低階適配) 的方法與新增適配層類似。其主要目標是減少模型的可訓引數量。LoRA 的主要做法是凍結預訓練權重,僅更新一個新增的低秩矩陣。
RoBERTa 支援的最大序列長度為 512,為公平起見,對所有模型,我們統一設定 MAX_LEN=512
。
MAX_LEN = 512
roberta_checkpoint = "roberta-large"
mistral_checkpoint = "mistralai/Mistral-7B-v0.1"
llama_checkpoint = "meta-llama/Llama-2-7b-hf"
資料載入
從 Hugging Face 載入資料集:
from datasets import load_dataset
dataset = load_dataset("mehdiiraqui/twitter_disaster")
將資料集分為訓練集和驗證集,同時載入測試集:
from datasets import Dataset
# 將資料集的訓練集劃分為訓練集和驗證集
data = dataset['train'].train_test_split(train_size=0.8, seed=42)
# 把劃分而得的測試集重新命名為驗證集
data['val'] = data.pop("test")
# 將原資料集的測試集仍作為測試集
data['test'] = dataset['test']
以下是資料集概覽:
DatasetDict({
train: Dataset({
features: ['id', 'keyword', 'location', 'text', 'target'],
num_rows: 6090
})
val: Dataset({
features: ['id', 'keyword', 'location', 'text', 'target'],
num_rows: 1523
})
test: Dataset({
features: ['id', 'keyword', 'location', 'text', 'target'],
num_rows: 3263
})
})
首先,檢查一下資料分佈:
import pandas as pd
data['train'].to_pandas().info()
data['test'].to_pandas().info()
RangeIndex: 7613 entries, 0 to 7612
Data columns (total 5 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 id 7613 non-null int64
1 keyword 7552 non-null object
2 location 5080 non-null object
3 text 7613 non-null object
4 target 7613 non-null int64
dtypes: int64(2), object(3)
memory usage: 297.5+ KB
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 3263 entries, 0 to 3262
Data columns (total 5 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 id 3263 non-null int64
1 keyword 3237 non-null object
2 location 2158 non-null object
3 text 3263 non-null object
4 target 3263 non-null int64
dtypes: int64(2), object(3)
memory usage: 127.6+ KB
訓練集中標籤分佈情況:
target
0 4342
1 3271
Name: count, dtype: int64
由於類別不平衡,我們計算一下正負類權重,以用於稍後的損失計算:
pos_weights = len(data['train'].to_pandas()) / (2 * data['train'].to_pandas().target.value_counts()[1])
neg_weights = len(data['train'].to_pandas()) / (2 * data['train'].to_pandas().target.value_counts()[0])
計算出的權重為:
POS_WEIGHT, NEG_WEIGHT = (1.1637114032405993, 0.8766697374481806)
接著,我們計算文字序列的最大長度:
# 字元數
max_char = data['train'].to_pandas()['text'].str.len().max()
# 詞數
max_words = data['train'].to_pandas()['text'].str.split().str.len().max()
The maximum number of characters is 152.
The maximum number of words is 31.
資料處理
以一條訓練資料為例:
data['train'][0]
{'id': 5285,
'keyword': 'fear',
'location': 'Thibodaux, LA',
'text': 'my worst fear. https://t.co/iH8UDz8mq3',
'target': 0}
該資料中包括關鍵字、位置和推文。為了簡單起見,我們選擇 text
特徵作為 LLM 的唯一輸入。
本階段的目標是為 LLM 微調準備所需的 Hugging Face 格式的訓練集、驗證集和測試集。然後是定義用於訓練的詞後設資料集,使用合適的分詞器將 text
特徵轉換為詞元 id 和注意力掩碼序列這兩個張量。由於每個模型都有其特定的分詞器,因此我們需要生成三個不同的資料集,每個模型一個。
我們首先定義 RoBERTa 模型的資料載入器:
from transformers import AutoTokenizer
roberta_tokenizer = AutoTokenizer.from_pretrained(roberta_checkpoint, add_prefix_space=True)
注意: RoBERTa 分詞器經過訓練已將空格視為詞元的一部分。因此,如果句子的第一個單詞前面沒有空格,則其編碼會有所不同。為了確保第一個單詞包含空格,我們設定 add_prefix_space=True
。同時,為了保持三個模型的預處理一致,我們將 Llama 2 和 Mistral 7B 的相應引數也設為 True
。
def roberta_preprocessing_function(examples):
return roberta_tokenizer(examples['text'], truncation=True, max_length=MAX_LEN)
將預處理常式應用於訓練資料集的第一條資料,我們得到了分詞後的輸入 ( input_ids
) 及其注意力掩碼:
roberta_preprocessing_function(data['train'][0])
{'input_ids': [0, 127, 2373, 2490, 4, 1205, 640, 90, 4, 876, 73, 118, 725, 398, 13083, 329, 398, 119, 1343, 246, 2], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}
col_to_delete = ['id', 'keyword','location', 'text']
# 刪除不需要的列,並應用預處理常式
roberta_tokenized_datasets = data.map(roberta_preprocessing_function, batched=True, remove_columns=col_to_delete)
# 按照 HuggingFace 的要求,將 `target` 列 重新命名為 `label` 列
roberta_tokenized_datasets = roberta_tokenized_datasets.rename_column("target", "label")
# 資料集格式設為 "torch"
roberta_tokenized_datasets.set_format("torch")
注意: 我們從資料中刪除了不需要的列: id
、 keyword
、 location
及 text
。刪除 text
的原因是我們已經將其轉換為輸入 id 和注意力掩碼:
分詞後的訓練資料集中的資料如下:
roberta_tokenized_datasets['train'][0]
{'label': tensor(0),
'input_ids': tensor([ 0, 127, 2373, 2490, 4, 1205, 640, 90, 4, 876,
73, 118, 725, 398, 13083, 329, 398, 119, 1343, 246,
2]),
'attention_mask': tensor([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1])}
DataCollatorWithPadding
類:# 資料整理器將所有資料統一填充至 batch 內最長序列的長度
from transformers import DataCollatorWithPadding
roberta_data_collator = DataCollatorWithPadding(tokenizer=roberta_tokenizer)
用相同的流程為 Mistral 7B 和 Llama 2 模型準備資料:
注意 Llama 2 和 Mistral 7B 沒有預設的 pad_token_id
,我們將其設為 eos_token_id
。
# 載入 Mistral 7B 分詞器
from transformers import AutoTokenizer, DataCollatorWithPadding
mistral_tokenizer = AutoTokenizer.from_pretrained(mistral_checkpoint, add_prefix_space=True)
mistral_tokenizer.pad_token_id = mistral_tokenizer.eos_token_id
mistral_tokenizer.pad_token = mistral_tokenizer.eos_token
def mistral_preprocessing_function(examples):
return mistral_tokenizer(examples['text'], truncation=True, max_length=MAX_LEN)
mistral_tokenized_datasets = data.map(mistral_preprocessing_function, batched=True, remove_columns=col_to_delete)
mistral_tokenized_datasets = mistral_tokenized_datasets.rename_column("target", "label")
mistral_tokenized_datasets.set_format("torch")
# 序列填充
mistral_data_collator = DataCollatorWithPadding(tokenizer=mistral_tokenizer)
# 載入 Llama 2 分詞器
from transformers import AutoTokenizer, DataCollatorWithPadding
llama_tokenizer = AutoTokenizer.from_pretrained(llama_checkpoint, add_prefix_space=True)
llama_tokenizer.pad_token_id = llama_tokenizer.eos_token_id
llama_tokenizer.pad_token = llama_tokenizer.eos_token
def llama_preprocessing_function(examples):
return llama_tokenizer(examples['text'], truncation=True, max_length=MAX_LEN)
llama_tokenized_datasets = data.map(llama_preprocessing_function, batched=True, remove_columns=col_to_delete)
llama_tokenized_datasets = llama_tokenized_datasets.rename_column("target", "label")
llama_tokenized_datasets.set_format("torch")
# 序列填充
llama_data_collator = DataCollatorWithPadding(tokenizer=llama_tokenizer)
至此,我們已經準備好了分詞後的資料集,下一節我們將討論如何載入預訓練 LLM 檢查點以及如何設定 LoRA 權重。
RoBERTa
為分類任務載入 RoBERTa 檢查點
我們使用 Hugging Face AutoModelForSequenceClassification
類載入帶有序列分類頭的預訓練 RoBERTa 模型:
from transformers import AutoModelForSequenceClassification
roberta_model = AutoModelForSequenceClassification.from_pretrained(roberta_checkpoint, num_labels=2)
RoBERTa 分類器的 LoRA 設定
我們為 RoBERTa 分類器設定 LoRA 引數:
以下程式碼使用了 LoRA 論文 的推薦設定。後文 我們還將用 wandb
對這些超參進行調優。
from peft import get_peft_model, LoraConfig, TaskType
roberta_peft_config = LoraConfig(
task_type=TaskType.SEQ_CLS, r=2, lora_alpha=16, lora_dropout=0.1, bias="none",
)
roberta_model = get_peft_model(roberta_model, roberta_peft_config)
roberta_model.print_trainable_parameters()
可以看到,可訓引數量僅佔 RoBERTa 模型引數量的 0.64%:
trainable params: 2,299,908 || all params: 356,610,052 || trainable%: 0.6449363911929212
Mistral
為分類任務載入檢查點
載入帶有序列分類頭的預訓練 Mistral-7B 模型:
from transformers import AutoModelForSequenceClassification
import torch
mistral_model = AutoModelForSequenceClassification.from_pretrained(
pretrained_model_name_or_path=mistral_checkpoint,
num_labels=2,
device_map="auto"
)
設定填充詞元 id,因為 Mistral 7B 沒有預設填充詞元。
mistral_model.config.pad_token_id = mistral_model.config.eos_token_id
Mistral 7B 分類器的 LoRA 設定
對 Mistral 7B 模型而言,我們需要指定 target_modules
(我們將其指定為注意力模組的查詢向量對映層和值向量對映層):
from peft import get_peft_model, LoraConfig, TaskType
mistral_peft_config = LoraConfig(
task_type=TaskType.SEQ_CLS, r=2, lora_alpha=16, lora_dropout=0.1, bias="none",
target_modules=[
"q_proj",
"v_proj",
],
)
mistral_model = get_peft_model(mistral_model, mistral_peft_config)
mistral_model.print_trainable_parameters()
可訓引數量僅佔 Mistral 模型引數量的 0.024%:
trainable params: 1,720,320 || all params: 7,112,380,416 || trainable%: 0.02418768259540745
Llama 2
為分類任務載入檢查點
載入帶有序列分類頭的預訓練 Llama 2 模型。
from transformers import AutoModelForSequenceClassification
import torch
llama_model = AutoModelForSequenceClassification.from_pretrained(
pretrained_model_name_or_path=llama_checkpoint,
num_labels=2,
device_map="auto",
offload_folder="offload",
trust_remote_code=True
)
設定填充詞元 id,因為 Llama 2 沒有預設填充詞元。
llama_model.config.pad_token_id = llama_model.config.eos_token_id
Llama 2 分類器的 LoRA 設定
使用與 Mistral 相同的 LoRA 引數:
from peft import get_peft_model, LoraConfig, TaskType
llama_peft_config = LoraConfig(
task_type=TaskType.SEQ_CLS, r=16, lora_alpha=16, lora_dropout=0.05, bias="none",
target_modules=[
"q_proj",
"v_proj",
],
)
llama_model = get_peft_model(llama_model, llama_peft_config)
llama_model.print_trainable_parameters()
可訓引數量僅佔 Llama 2 模型引數量的 0.12%:
trainable params: 8,404,992 || all params: 6,615,748,608 || trainable%: 0.1270452143516515
至此,我們定義了用於訓練的詞後設資料集及 LoRA 設定。下面,我們介紹如何使用 Hugging Face 的 Trainer
類啟動訓練。
Trainer
評估指標
首先,我們定義用於對三個模型的效能進行比較的指標: F1 分數、召回率、精確度和準確度:
import evaluate
import numpy as np
def compute_metrics(eval_pred):
# HF `evaluate` 包已支援我們所要的所有指標
precision_metric = evaluate.load("precision")
recall_metric = evaluate.load("recall")
f1_metric= evaluate.load("f1")
accuracy_metric = evaluate.load("accuracy")
logits, labels = eval_pred
# eval_pred 是模型返回的預測值和實際值元組
predictions = np.argmax(logits, axis=-1)
precision = precision_metric.compute(predictions=predictions, references=labels)["precision"]
recall = recall_metric.compute(predictions=predictions, references=labels)["recall"]
f1 = f1_metric.compute(predictions=predictions, references=labels)["f1"]
accuracy = accuracy_metric.compute(predictions=predictions, references=labels)["accuracy"]
# `Trainer` 要求將指標組織為一個字典,其鍵為指標名,值為分數。
return {"precision": precision, "recall": recall, "f1-score": f1, 'accuracy': accuracy}
基於加權損失的自定義 Trainer
前文提到,資料集正負類分佈並不平衡。因此,我們用加權交叉熵損失來訓練模型以解決這個問題。 Trainer
類本身的實現中不支援自定義損失,因為它期望直接從模型的輸出中獲取損失。
因此,我們需要定義一個自定義的 WeightedCELossTrainer
,以重寫 compute_loss
方法,該方法可以根據模型的預測和標籤計算加權交叉熵損失:
from transformers import Trainer
class WeightedCELossTrainer(Trainer):
def compute_loss(self, model, inputs, return_outputs=False):
labels = inputs.pop("labels")
# Get model's predictions
outputs = model(**inputs)
logits = outputs.get("logits")
# Compute custom loss
loss_fct = torch.nn.CrossEntropyLoss(weight=torch.tensor([neg_weights, pos_weights], device=model.device, dtype=logits.dtype))
loss = loss_fct(logits.view(-1, self.model.config.num_labels), labels.view(-1))
return (loss, outputs) if return_outputs else loss
Trainer 設定
我們為三個模型分別設定訓練超參及訓練器。
RoBERTa
第一步,把模型搬到 GPU 裝置上。
roberta_model = roberta_model.cuda()
roberta_model.device()
It will print the following:
device(type='cuda', index=0)
然後,設定訓練超參:
from transformers import TrainingArguments
lr = 1e-4
batch_size = 8
num_epochs = 5
training_args = TrainingArguments(
output_dir="roberta-large-lora-token-classification",
learning_rate=lr,
lr_scheduler_type= "constant",
warmup_ratio= 0.1,
max_grad_norm= 0.3,
per_device_train_batch_size=batch_size,
per_device_eval_batch_size=batch_size,
num_train_epochs=num_epochs,
weight_decay=0.001,
evaluation_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
report_to="wandb",
fp16=False,
gradient_checkpointing=True,
)
最後,我們將模型、訓練超參和詞後設資料集一起作為引數來範例化一個 RoBERTa 訓練器:
roberta_trainer = WeightedCELossTrainer(
model=roberta_model,
args=training_args,
train_dataset=roberta_tokenized_datasets['train'],
eval_dataset=roberta_tokenized_datasets["val"],
data_collator=roberta_data_collator,
compute_metrics=compute_metrics
)
Mistral-7B
與 RoBERTa 類似,我們用如下程式碼初始化 WeightedCELossTrainer
:
from transformers import TrainingArguments, Trainer
mistral_model = mistral_model.cuda()
lr = 1e-4
batch_size = 8
num_epochs = 5
training_args = TrainingArguments(
output_dir="mistral-lora-token-classification",
learning_rate=lr,
lr_scheduler_type= "constant",
warmup_ratio= 0.1,
max_grad_norm= 0.3,
per_device_train_batch_size=batch_size,
per_device_eval_batch_size=batch_size,
num_train_epochs=num_epochs,
weight_decay=0.001,
evaluation_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
report_to="wandb",
fp16=True,
gradient_checkpointing=True,
)
mistral_trainer = WeightedCELossTrainer(
model=mistral_model,
args=training_args,
train_dataset=mistral_tokenized_datasets['train'],
eval_dataset=mistral_tokenized_datasets["val"],
data_collator=mistral_data_collator,
compute_metrics=compute_metrics
)
注意,我們需要將 fp16
設為 True
以啟用半精度訓練。主要原因是 Mistral-7B 很大,如果使用 fp32 精度,其權重無法放進單塊 GPU 的視訊記憶體 (48GB) 中。
Llama 2
與 Mistral 7B 類似,我們用如下程式碼定義訓練器:
from transformers import TrainingArguments, Trainer
llama_model = llama_model.cuda()
lr = 1e-4
batch_size = 8
num_epochs = 5
training_args = TrainingArguments(
output_dir="llama-lora-token-classification",
learning_rate=lr,
lr_scheduler_type= "constant",
warmup_ratio= 0.1,
max_grad_norm= 0.3,
per_device_train_batch_size=batch_size,
per_device_eval_batch_size=batch_size,
num_train_epochs=num_epochs,
weight_decay=0.001,
evaluation_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
report_to="wandb",
fp16=True,
gradient_checkpointing=True,
)
llama_trainer = WeightedCELossTrainer(
model=llama_model,
args=training_args,
train_dataset=llama_tokenized_datasets['train'],
eval_dataset=llama_tokenized_datasets["val"],
data_collator=llama_data_collator,
compute_metrics=compute_metrics
)
我們用 Wandb Sweep API 通過貝葉斯搜尋策略來進行超參調優 (30 次執行),待調優的超參搜尋空間如下:
方法 | 指標 | lora_alpha | lora_bias | lora_dropout | lora_rank | lr | max_length |
---|---|---|---|---|---|---|---|
bayes | 目標: maximize | 分佈: categorical | 分佈: categorical | 分佈: uniform | 分佈: categorical | 分佈: uniform | 分佈: categorical |
目標名: eval/f1-score | 取值集合: -16 -32 -64 |
取值集合: None | -最大值: 0.1 -最小值: 0 |
取值集合: -4 -8 -16 -32 |
-最大值: 2e-04 -最小值: 1e-05 |
取值集合: 512 |
欲瞭解更多資訊,可以檢視 資源 一節中的 Wandb 實驗報告。
模型 | F1 分數 | 訓練時間 | 記憶體消耗 | 可訓引數量 |
---|---|---|---|---|
RoBERTa | 0.8077 | 538 秒 | GPU1: 9.1 GB GPU2: 8.3 GB |
0.64% |
Mistral 7B | 0.7364 | 2030 秒 | GPU1: 29.6 Gb GPU2: 29.5 GB |
0.024% |
Llama 2 | 0.7638 | 2052 秒 | GPU1: 35 GB GPU2: 33.9 GB |
0.12% |
本文我們用 LoRA 對三個大語言模型 (LLM) (RoBERTa、Mistral 7B 及 Llama 2) 針對災難推文分類任務進行微調。從效能結果來看,RoBERTa 的效能大幅優於 Mistral 7B 和 Llama 2。這就提出了一個問題: 我們是否真的需要一個大而複雜的 LLM 來完成諸如短序列二分類這樣的簡單任務?
一個重要的啟示是,在選擇要使用的 LLM 模型時應該考慮具體的專案要求、可用資源和效能需求。
此外,對於針對短序列的相對 簡單 的預測任務,小的基礎模型 (例如 RoBERTa) 仍然具有競爭力。
最後,我們還通過例子展示了 LoRA 方法的通用性,其既可應用於編碼器 (RoBERTa) 模型,還可應用於解碼器 (Llama 2 及 Mistral 7B) 模型。
英文原文: https://hf.co/blog/Lora-for-sequence-classification-with-Roberta-Llama-Mistral
原文作者: Mehdi Iraqi
譯者: Matrix Yao (姚偉峰),英特爾深度學習工程師,工作方向為 transformer-family 模型在各模態資料上的應用及大規模模型的訓練推理。