終於來到我們這個系列的最後一個階段啦~今天的主要內容是教你如何運用RLHF與QLoRA來調整這些龐大的語言模型。在這個部分裡,如果你在網路上查詢資料,可能會發現這些程式都是一些經過精心打包的專案或函式庫來協助你訓練,但是這會讓你在實際練習時無法理解其程式原理,因此在這裡我們將採用本系列文章的程式風格,並且一步步地引導你完成這次的程序,今天的學習重點如下:
QLoRA的技術源自於QLoRA: Efficient Finetuning of Quantized LLMs這篇期刊論文,其主要創新之處在於使用4bit
來壓縮模型,並且其微調效能與16bit
的相當接近,QLoRA
的運作原理與LoRA
基本相同,但它使用了一種新型的資料型態4位元NormalFloat
來表達模型資料,並配合記憶體管理技術來優化操作。
根據作者的實驗結果,使用QLoRA
微調的模型甚至能以較小的參數量達成部分SOTA
模型的成績,而今天我們將需要此技術來幫助我們完成微調LLaMA
2這一個語言模型,我們先來看看以下的步驟。
這次我們可以選擇兩個資料集進行訓練。第一種是openai_summarize_comparisons資料集,該資料集提供了模型生成後經人工選擇的資料,以及被人工拒絕的資料,有助於我們快速完成RLHF
的任務,而第二種選項是使用PTT 中文語料以協助我們訓練出能針對繁體中文回答的鄉民版本LLaMA 2模型,但這需要我們自行生成文字並用RLHF
進行調整,這次我將選用第二個資料集作為實際訓練的範例,因為它和我們模型上線時的操作方法較為接近。
首先我們先到Hugging Face網站,隨找到到一個官方版本的Llama 2的模型在這裡我將會使用Llama-2-7b-chat-hf作為範例。
在該頁面中我們需先到Meta的官方網站申請模型的使用權限,在這一步只要資料填寫正確,基本上馬上就會收到審核通過的Email。當審核通過我們就能夠回到Hugging Face的官方網站,使用你審核時所用的Email進行註冊或登入,這樣才能申請模型的權限。
在模型申請完成後,我們需要前往右上方的設定,來建立一個代表你的 Hugging Face 帳號的 token,當成功建立 token 之後,我們就可以在載入模型時,使用此 token 獲得模型下載權重的權限。
from transformers import AutoModel
access_token = "你的token"
model = AutoModel.from_pretrained("private/model", token=access_token)
或是我們也能透過huggingface-cli
來預先設定Token於我們的電腦環境中,如此一來我們就不需要在每次載入模型時都重新輸入token。
huggingface-cli login
huggingface-cli login --token $你的token
接下來我們將運用Llama-2-7b-chat-hf
這一個模型,這是Llama 2針對聊天專用所微調的版本,而該模型的的讀取方式,我們需要透過4位元NormalFloat(nf4)
來載入模型權重,因為該模型的參數量極大。這與我們先前使用的LoRA的程式相似,但有一點不同就是我們還需要利用BitsAndBytesConfig
來創建模型參數,隨後再將它們傳遞給AutoModelForCausalLM
。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
base_model_id = "meta-llama/Llama-2-7b-chat-hf"
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16
)
model = AutoModelForCausalLM.from_pretrained(base_model_id, quantization_config=bnb_config)
接下來我們同樣會透過PEFT函式庫來轉換模型的結構,同時開啟QLoRA在計算梯度時的檢查點。
from peft import prepare_model_for_kbit_training
model.gradient_checkpointing_enable()
model = prepare_model_for_kbit_training(model)
之後的用法就是看你想要對模型在哪些地方需要用到QLoRA的方式進行調整,並且將其輸入到target_modules
即可,在這裡的用法與我們之前的並無任何差異。
from peft import LoraConfig, get_peft_model
config = LoraConfig(
r=32,
lora_alpha=64,
target_modules=[
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"gate_proj",
"up_proj",
"down_proj",
"lm_head",
],
bias="none",
lora_dropout=0.05, # Conventional
task_type="CAUSAL_LM",
)
model = get_peft_model(model, config)
在我們檢查模型參數量的時候可以發現,與LoRA相比QLoRA在模型壓縮率上更佳,以LLaMa-7b模型為例,我們甚至可以壓縮至僅剩下約2%的參數量。
def print_trainable_parameters(model):
trainable_params = 0
all_param = 0
for _, param in model.named_parameters():
all_param += param.numel()
if param.requires_grad:
trainable_params += param.numel()
print(
f"trainable params: {trainable_params} || all params: {all_param} || trainable%: {100 * trainable_params / all_param}"
)
print_trainable_parameters(model)
#-------------輸出-------------
trainable params: 81108992 || all params: 3581521920 || trainable%: 2.264651559077991
在Llama-2-chat-hf
版本中,因在微調時是使用一定格式進行的,所以我們需要遵循這種格式,以便讓模型理解每一輪的對話內容,而對於模型的單輪對話輸入,其格式如下:
<s>[INST] <<SYS>>
{{ system_prompt }}
<</SYS>>
{{ user_message }} [/INST]
其中,<s>
代表文字的開頭,[INST]
包含在這輪對話的所有內容,因此對於多輪對話的輸入,我們可以遵照以下格式:
<s>[INST] <<SYS>>
{{ system_prompt }}
<</SYS>>
{{ user_msg_1 }} [/INST] {{ model_answer_1 }} </s><s>[INST] {{ user_msg_2 }} [/INST] {{ model_answer_2 }} </s><s>[INST] {{ user_msg_3 }} [/INST]
在這個格式中的第一輪的對話主要由system
與user
進行對話與模型的輸入,而在後續的對話中則由model
與user
進行,因此現階段的在微調時,我們需要將問答資料轉換成該格式,不過該格式稍顯複雜,所以我選擇使用ChatGPT的輸入方式來轉換成LLaMA 2模型的實際輸入,所以我們需撰寫一個函數進行轉換:
def format_dialogue_prompt(messages, system_prompt="你是一個在社群網路上回覆訊息的用戶"):
# 定義特殊標記
INST_START, INST_END = "[INST]", "[/INST]"
SYS_START, SYS_END = "<<SYS>>\n", "\n<</SYS>>\n\n"
BOS, EOS = "<s>", "</s>"
# 在對話開始處添加系統提示
system_instruct = f'{BOS}{INST_START} {SYS_START}{system_prompt}{SYS_END}'
context = []
context_cnt = 0
for message in messages:
role = message['role']
if context_cnt % 2 == 0 and role == 'user':
content = message['content']
context.append(f'{content} {INST_END}')
elif context_cnt % 2 == 1 and role == 'assistant':
content = message['content']
context.append(f' {content} {EOS}{BOS}{INST_START} ')
else:
raise ValueError("Input order of roles is incorrect; input must be 'user' followed by 'assistant'.")
context_cnt += 1
# 組合對話提示
output = system_instruct + "".join(context)
# 如果結尾不是assistant,返回完整的prompt
if role != 'assistant':
return output
else:
return output[:-len(BOS + INST_START)-1]
當我們使用ChatGPT的輸入格式時,就能夠順利地轉換成LLaMA 2的格式了。
messages = [
{'role':'user', 'content': '你今天看起來很開心?'},
{'role':'assistant', 'content': '對阿'},
{'role':'user', 'content': '為什麼?'},
{'role':'assistant', 'content': '因為我今天走在路上撿到錢'},
{'role':'user', 'content': '分喔'},
]
formatted_prompt = format_dialogue_prompt(messages)
print(formatted_prompt)
#-------------輸出-------------
<s>[INST] <<SYS>>
你是一個在社群網路上回覆訊息的用戶
<</SYS>>
你今天看起來很開心? [/INST] 對阿 </s><s>[INST] 為什麼? [/INST] 因為我今天走在路上撿到錢 </s><s>[INST] 分喔 [/INST]
這樣我們就能將資料讀取進來後,運用ChatGPT的QA格式轉換成LLaMA 2的格式以建立我們的資料集,不過由於該資料集的資料量龐大,約有超過100萬筆,因此在進行測試時我們可以先自行將資料量縮減。
import pandas as pd
df = pd.read_csv('Gossiping-QA-Dataset-2_0.csv' , encoding='utf-8-sig').values
data = []
for question, answer in df:
print(question)
qa = [
{'role':'user', 'content': f'{question}'},
{'role':'assistant', 'content': f'{answer}'}
]
data.append(llama_v2_prompt(qa))
我們同樣會在經過train_test_split
後,利用collate_fn
進行填充的動作,在這過程中我們所採取的策略與訓練GPT-J的方法相同,都是直接使用最大長度進行填充,超過的部分則進行截斷。
from torch.utils.data import Dataset, DataLoader
import torch
class QAdataset(Dataset):
def __init__(self, x):
self.x = x
def __getitem__(self, index):
return self.x[index]
def __len__(self):
return len(self.x)
def collate_fn(batch):
x = list(batch)
x = tokenizer(x, truncation=True, padding="longest", return_tensors='pt')
return {**x, 'labels':x.input_ids}
x_train, x_valid = train_test_split(x_data, train_size=0.8, random_state=46, shuffle=False)
trainset = QAdataset(x_train)
validset = QAdataset(x_train)
train_loader = DataLoader(trainset, batch_size = 32, shuffle = True, num_workers = 0, collate_fn = collate_fn)
valid_loader = DataLoader(validset, batch_size = 32, shuffle = True, num_workers = 0, collate_fn = collate_fn)
我們使用這種方式的好處在這裡就能夠得到充分的體現,因為在Hugging face中,模型的輸入基本上並無太大的差異。所以我們在進行訓練時,無需對程式碼進行大幅度的修改,只需調整collate_fn
的傳入參數即可。
def train(epoch):
train_loss = 0
train_pbar = tqdm(train_loader, position=0, leave=True) # 宣告進度條
model.train()
for input_datas in train_pbar:
for key in input_datas.keys():
input_datas[key] = input_datas[key].to(device)
optimizer.zero_grad()
outputs = model(**input_datas)
loss = outputs.loss
loss.backward()
optimizer.step()
train_pbar.set_description(f'Train Epoch {epoch}')
train_pbar.set_postfix({'loss':f'{loss:.3f}'})
train_loss += loss.item()
return train_loss/len(train_loader)
不過這次的訓練量相當巨大,以單張3090顯示卡訓練70萬筆資料的時間已經達到了一週,因此我在這裡只設定了進行一次訓練。
epochs = 1 # 訓練次數
early_stopping = 0 # 模型訓練幾次沒進步就停止
stop_cnt = 0 # 計數模型是否有進步的計數器
model_path = 'model.ckpt' # 模型存放路徑
show_loss = False # 是否顯示訓練折線圖
best_loss = float('inf') # 最佳的Loss
loss_record = {'train':[], 'valid':[]} # 訓練紀錄
for epoch in range(epochs):
train_loss = train(epoch)
valid_loss = valid(epoch)
loss_record['train'].append(train_loss)
loss_record['valid'].append(valid_loss)
# 儲存最佳的模型權重
if valid_loss < best_loss:
best_loss = valid_loss
torch.save(model.state_dict(), model_path)
print(f'Saving Model With Loss {best_loss:.5f}')
stop_cnt = 0
else:
stop_cnt+=1
# Early stopping
if stop_cnt == early_stopping:
output = "Model can't improve, stop training"
print('-' * (len(output)+2))
print(f'|{output}|')
print('-' * (len(output)+2))
break
print(f'Train Loss: {train_loss:.5f}' , end='| ')
print(f'Valid Loss: {valid_loss:.5f}' , end='| ')
print(f'Best Loss: {best_loss:.5f}', end='\n\n')
if show_loss:
show_training_loss(loss_record)
雖然在訓練一次的狀況下,我們對訓練的最終效能並不十分清楚,但從訓練初期至今,我觀察到模型的Loss值從4開始逐步降低,並在0.02的地方穩定下來。
Train Epoch 0: 100%|█████████████████████████████████████████████| 19353/19353 [187:01:34<00:00:00, 93.54s/it, loss=0.0234]
而在這一步我們實際上已可將模型作為後端上傳到網路來使用,但該模型的其中一項亮點,就是我們也能夠像ChatGPT一樣讓模型進行RLHF的操作,假設在使用著前端運行了下列程式並對產生的結果不滿意。
messages = [
{'role':'user', 'content': '你今天看起來很開心?'},
{'role':'assistant', 'content': '對阿'},
{'role':'user', 'content': '為什麼?'},
{'role':'user', 'content': '因為我今天走在路上撿到錢'},
]
formatted_prompt = format_dialogue_prompt(messages)
inputs = tokenizer(formatted_prompt, return_tensors="pt")
sentence_A = model.generate(**inputs, max_length=800) # 正面回覆
sentence_B = model.generate(**inputs, max_length=800) # 被拒絕的回覆
這時使用者通常會點下重新生成的動作。這樣我們將會產生兩句不同的sentence
,這時我們就可以建立損失函數的計算函數,以此計算出獎勵與懲罰機制的結果。
def RLHF_loss(sentence_A, sentence_B):
j = tokenizer(sentence_A, return_tensors="pt")
k = tokenizer(sentence_B, return_tensors="pt")
rewards_j = model(**j)[0]
rewards_k = model(**k)[0]
loss = -nn.functional.logsigmoid(rewards_j - rewards_k).mean()
retuen loss
這時我們就能計算出每次生成後的RLHF損失結果,使其能根據根據用戶的反饋進行調整,當然你也可以直接使用我們一開始提及的資料集1進行訓練與比對。
messages = [
{'role':'user', 'content': '你今天看起來很開心?'},
{'role':'assistant', 'content': '對阿'},
{'role':'user', 'content': '為什麼?'},
{'role':'user', 'content': '因為我今天走在路上撿到錢'},
]
formatted_prompt = format_dialogue_prompt(messages)
inputs = tokenizer(formatted_prompt, return_tensors="pt")
sentence_A = model.generate(**inputs, max_length=800) # 正面回覆
sentence_B = model.generate(**inputs, max_length=800) # 被拒絕的回覆
loss = RLHF_loss(sentence_A, sentence_B)
loss.backward()
optimizer.step()
在這裡因為RLHF非常花費時間,所以我只進行了10次測試,而這時模型的QA問答已經表現相當優秀,當我們看到以下模型的生成結果,可以發現此模型已經非常熟悉PTT鄉民的回答風範。
雖然我使用十次的RLHF來調整模型可能不會帶來很大的影響,所以通常我們可以把這個訓練好的LLaMA進行部署後,撰寫一個與ChatGPT類似的網站,進而讓使用者協助我們調整這個模型。
# [0]
輸入: PTT的水準越來越差了
輸出: 你自己程度差少來這邊秀下限
# [1]
輸入: 同志婚姻早該合法,為何拖到今天?
輸出: 什麼時候輪到近親婚姻
# [2]
輸入: 宅宅可以跟二次元合法結婚了嗎?
輸出: 初音犯重婚罪不用負責咪
以上也就是在單張顯示卡上進行QLoRA與RLHF的所有內容,而有關RLHF的損失函數計算,我們可以參考不同論文的作法以找到模型最佳的損失值。
我們終於完成了最後一個模型的理論學習與訓練方式,相較於前段的內容,你可能已注意到後續部分的公式大幅度減少了? 這是因為大部分的模型變化並不大,主要都以Transformer的架構作為基礎進行在刪減與改良,因此與先前的基礎公式比較,這部分的公式並未出現太多變化。
而這種大型語言模型對於許多企業來說,如何以最低的成本進行模型訓練,已經成為了一個全新的挑戰,以我們此次訓練的7b
的LLaMA為例,在中研院的研究中已經投入了約30萬的資金來調整,所以我們文章中的主要目標,是讓你能使用單張顯示卡去運作這些大型語言模型並學習調整的方式,以減少這些不必要的花費。
對於正在學習的我們來說,理解這些策略的原理才是最為重要的,因為在未來我們有可能會自行開發出自己的模型,這時舊有的理論就顯得相當關鍵,因此我將在明日協助你整理過去30天的重點,讓你能夠統整這些語言模型的奧秘!
那麼我們明天再見!
內容中的程式碼都能從我的GitHub上取得:
https://github.com/AUSTIN2526/iThome2023-learn-NLP-in-30-days