今天我們要來談談一個很好玩的資料集"弱智吧",沒錯你沒看錯就是那個在網路上以瘋言瘋語、奇思妙想著稱的討論區。乍看之下這種地方的對話充滿跳針、無厘頭,甚至讓人懷疑發言者是不是認真在講話。但也正因如此這類資料特別適合拿來做語言模型的微調訓練,因為它具備了高度非結構化、多樣語境轉換與語言風格突變的特性——這些恰恰是測試與強化模型對話理解能力的絕佳素材。
而在今天我們先告訴你一個LLM怎麼做的,然後再一步步實作如何用"弱智吧"資料,搭配 Chat 模型格式並加入NEFtune這項技術,打造一個不只能理解亂流對話,還能用哲學角度回應你的人文風格聊天機器人。
我們先前使用的 GPT‑2 是典型的 base 模型。它在文字生成上表現不俗,能接續段落、創作詩句、撰寫簡短故事,但若給它請總結下列文章或進行多輪對話這類指令型任務,它常常跑題,無法準確執行任務或維持對話連貫性。這是因為 base 模型雖具備語言能力,但缺乏任務導向與上下文理解的機制。
到了 GPT‑3,模型參數量大幅提升,使其在翻譯、摘要、問答、寫程式等多種自然語言處理任務上展現更高水準。不過,原始的 GPT‑3 模型仍然只是被動地接續輸入文字,對於明確執行人類指令這件事並不擅長,回應品質也時常忽高忽低、不穩定。
為了改善這個問題,OpenAI 開發了 InstructGPT。它是在 GPT‑3 的基礎上加入了一套關鍵訓練流程指令微調(instruction tuning)
搭配 RLHF(Reinforcement Learning from Human Feedback)
。RLHF 的主要目的是讓模型學會產出人類認為好的回答
而實際做法是會讓 base 模型針對同一個 prompt 生成多個回答,並請人類標註者對這些回答依品質進行排序。這些排序資料會用來訓練一個 獎勵模型(Reward Model),這個模型能學會模擬人類偏好,對語言模型輸出的文字進行評分。
接著語言模型會利用這個獎勵模型的評分進行強化學習,常用的方法是 PPO(Proximal Policy Optimization)。透過 PPO,語言模型的生成策略會逐步朝向人類偏好靠攏,例如更清晰、有條理、符合語境或更具禮貌。這樣的調校大幅提升了語言模型的任務執行力與對話品質。從 InstructGPT 開始,模型能更準確地依據指令回應,理解使用者意圖,並避免不當或偏差的內容。這也為 ChatGPT 的誕生奠定基礎。
ChatGPT(GPT‑3.5)與 GPT‑4這種透過 RLHF 調教出來的對話能力更加成熟。這些模型不僅能進行上下文連貫的多輪對話,還能維持語氣一致、合理拒絕敏感請求,甚至在對話中穿插幽默或進行自我澄清。
簡單來說一個 LLM 的出現過程大致如下,首先從大量語料訓練出一個預訓練的 base 模型,接著透過 instruction tuning 讓模型具備基本的任務理解與指令回應能力,轉化為 Chat 形式。之後,開發者會收集人類標註者針對模型回答的偏好排序,用來訓練一個獎勵模型,使其能對回應進行自動評分。最後模型根據這些評分結果,透過 PPO進行強化學習,逐步學會產出更符合人類期待的回應。
在中文互聯網文化中弱智吧是一個非常獨特的存在。它原本是百度貼吧中的一個子版塊,內容充滿了看似荒謬、邏輯混亂甚至無厘頭的貼文。這些發言的共同特點是:語言誇張、思路跳脫、常見諧音與反諷。一句話形容的話,「越是正常的說法,越不合這個貼吧的胃口」。
乍看之下這樣的內容似乎沒什麼價值,甚至顯得低俗或反智。但有趣的是近年來這類語言風格反而在人工智慧領域引起關注,特別是在中文語言模型的訓練與微調階段。研究者發現與其使用過度乾淨、正規的語料,不如加入這類語義扭曲、邏輯不穩定的文本,讓模型學會如何處理更複雜的語言變體。例如弱智吧中的問題經常帶有雙關、多義、反問或模稜兩可的用詞,這些正好可以訓練模型面對語言的灰色地帶,而今天我們將要用這個資料集進行模型的訓練。
今天我們用的資料一樣是從 m-a-p/COIG-CQIA 這個資料集中提取的弱智吧內容,大家可以直接到我的 GitHub 頁面下載資料。至於今天要用的模型,是 Chat 版本的,不像之前我們用的 GPT-2 base model 那樣。這個 Chat 模型是透過微調原本的 base model 來實現的,所以它可以更好地理解並判斷多人對話的情境,例如在LLaMA 3上他的特定輸入格式是:
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
這是系統指令<|eot_id|><|start_header_id|>user<|end_header_id|>
這是用戶的輸入<|eot_id|><|start_header_id|>assistant<|end_header_id|>
這是模型回復<|eot_id|>
這格式看起來滿複雜的對吧?早期在 HuggingFace上用這類模型的時候,我們還得自己手動加這些 token,真的蠻麻煩的。而且不同的 Chat 模型格式還都不一樣,導致我們寫程式的時候很難統一處理。不過現在比較方便了,只要你用的是 Chat 版本的 tokenizer,它通常都會自帶一個叫做 apply_chat_template
的方法,直接就可以把對話格式套進去。這個方法的用法跟現在 ChatGPT API 裡的輸入格式很像,就是一個由多個角色(像 system、user、assistant)組成的訊息列表(messages)。其中 system 就是我們拿來放指令的地方,所以你可以這樣寫程式碼:
import pandas as pd
def transform_format(instructions, outputs, system="你是一個繁體中文聊天機器人"):
data = []
for q, a in zip(instructions, outputs):
data.append([
{"role": "system", "content": system},
{"role": "user", "content": q},
{"role": "assistant", "content": a}
])
return data
df = pd.read_csv("ruozhiba_trad.csv", encoding="utf-8")
df = df.dropna(subset=["instruction", "output"])
formatted = transform_format(df["instruction"], df["output"])
我們先來理解一下關於大型語言模型(LLM)的一個基本概念所謂的 Chat 版本,其實是從 base 版本的 LLM 開始,透過 SFT(Supervised Fine-Tuning)
這種微調的方式訓練出來的。這個過程同時也會搭配我們在第 25 天講過的 Instruction Learning 技術,讓模型能聽得懂任務的指令,並且學會哪些回答該給、哪些不能亂講。
當我們用 prepare_model_for_kbit_training
這個函數來處理模型,其實就是在幫模型做好低位元訓練的準備工作。這一步的主要目的是讓模型在只用少量精度的情況下,還能穩定地訓練,不會因為精度損失導致梯度亂跳、效果變差。順便複習一下這個函數會做幾件事,它會把模型原本的大部分參數凍結起來,這樣可以省下很多資源,然後也會啟用 gradient checkpointing,來進一步節省記憶體用量。
做好這些準備後,我們就可以把 LoRA 模組加進來了,具體來說我們會針對 k
、q
、v
、o
這些部分進行訓練,這樣就完成了模型量化跟 LoRA 組件的整合。
from transformers import BitsAndBytesConfig, AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
import torch
def load_llama_model(model_name='meta-llama/Meta-Llama-3-8B-Instruct'):
quantization_params = {
'load_in_4bit': True,
'bnb_4bit_quant_type': "nf4",
'bnb_4bit_use_double_quant': True,
'bnb_4bit_compute_dtype': torch.bfloat16
}
bnb_config = BitsAndBytesConfig(**quantization_params)
tokenizer = AutoTokenizer.from_pretrained(model_name, use_fast=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
torch_dtype=torch.bfloat16,
device_map="auto",
use_cache=False,
)
peft_params = {
'r': 32,
'target_modules': ["q_proj", "k_proj", "v_proj", "o_proj"],
'lora_dropout': 0.1,
'task_type': "CAUSAL_LM",
}
peft_config = LoraConfig(**peft_params)
model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True)
model = get_peft_model(model, peft_config)
return model, tokenizer
model = load_llama_model()
我們要把 NEFTune 技術加進模型裡。簡單來說NEFTune 是一種在訓練期間,針對輸入的嵌入加上一點隨機噪音的小技巧。雖然看起來只是加點 noise,但這其實對訓練很有幫助。為什麼要這麼做?因為在低精度訓練的情況下,模型對輸入變化的敏感度會變得比較高,這就容易讓訓練不穩定。NEFTune 就像是在這種不穩的情況下,給模型多一點彈性,讓它能更穩定地收斂。
根據 原始論文,這個方法實際在某些資料集上,甚至可以讓 LLaMA 模型的效能提升接近兩倍,效果非常驚人。這也是為什麼現在越來越多人在進行微調時會主動加上 NEFTune,而在程式上我們可以如此撰寫
from transformers.modeling_utils import unwrap_model
def activate_neftune(model, neftune_noise_alpha = 5):
unwrapped_model = unwrap_model(model)
embeddings = unwrapped_model.base_model.model.get_input_embeddings()
embeddings.neftune_noise_alpha = neftune_noise_alpha
# hook embedding layer
hook_handle = embeddings.register_forward_hook(neftune_post_forward_hook)
return model
def neftune_post_forward_hook(module, input, output):
# 公式來源:https://github.com/neelsjain/NEFTune
# 論文網址:https://arxiv.org/abs/2310.05914
if module.training:
dims = torch.tensor(output.size(1) * output.size(2))
mag_norm = module.neftune_noise_alpha / torch.sqrt(dims)
output = output + torch.zeros_like(output).uniform_(-mag_norm, mag_norm)
return output
model = activate_neftune(model)
而在這裡的 activate_neftune
函數,主要是負責把 NEFTune 整合進我們的模型。而透過 unwrap_model
這個工具,把模型外層的包裝拆掉,取得最底層、也就是實際運作的模型架構。接著我們會定位到模型的輸入嵌入層,這是模型接收文字資料的第一個處理環節。
接下來我們會設定一個參數叫 neftune_noise_alpha
,這個值決定了噪音的強度。設定好之後我們會在嵌入層上註冊一個 forward hook
。這個 hook 的功能是在每次模型做前向傳遞時,自動在輸出嵌入上加上一點隨機噪音。
當我們在使用 PyTorch 的 DataLoader 這塊時,整體流程其實和之前差不多。因為我們前面已經把格式處理好了,所以這邊只要直接套用 apply_chat_template
就能完成轉換。而且一樣要記得一件事處理 labels 的時候,要把那些 padding 的地方遮蔽起來,這點我們前面講過好幾次了。因為在訓練像這種 causal language model 時,這個步驟是絕對不能少的。
from torch.utils.data import Dataset, DataLoader
# 定義自定義 Dataset
class PTTDataset(Dataset):
def __init__(self, formatted_context, tokenizer):
self.formatted_context = formatted_context
self.tokenizer = tokenizer
def __getitem__(self, index):
return self.formatted_context[index]
def __len__(self):
return len(self.formatted_context)
def collate_fn(self, batch):
formatted_contexts = self.tokenizer.apply_chat_template(batch, padding=True, return_dict=True, max_length=8192, return_tensors='pt', truncation=True)
attention_mask = formatted_contexts['attention_mask']
labels = formatted_contexts['input_ids'].clone()
labels[attention_mask == 0] = -100
formatted_contexts['labels'] = labels
return formatted_contexts
# 建立資料集
trainset = PTTDataset(formatted, tokenizer)
validset = PTTDataset(formatted, tokenizer)
# 創建 DataLoader
train_loader = DataLoader(trainset, batch_size=4, shuffle=True, collate_fn=trainset.collate_fn)
valid_loader = DataLoader(validset, batch_size=4, shuffle=True, collate_fn=validset.collate_fn)
為什麼會這樣呢?這是因為 LoRA 的設計本身是輕量化的,只是在模型的一小部分(像是 attention weights)中插入少量可訓練的參數並沒有去動整個模型的主要權重,如果你學習率設得太高,那些少數參數很容易就會發散,讓模型的訓練變得不穩定。
import torch.optim as optim
from transformers import get_cosine_with_hard_restarts_schedule_with_warmup
from trainer import Trainer
optimizer = optim.AdamW(model.parameters(), lr=5e-5)
scheduler = get_cosine_with_hard_restarts_schedule_with_warmup(
optimizer,
num_warmup_steps=len(train_loader) * 0.2,
num_training_steps=len(train_loader) * 10,
num_cycles=1,
)
trainer = Trainer(
epochs=10,
train_loader=train_loader,
valid_loader=valid_loader,
model=model,
optimizer=optimizer,
scheduler=scheduler,
early_stopping=3,
is_lora=True
)
trainer.train()
輸出結果:
rain Epoch 0: 100%|██████████| 60/60 [00:25<00:00, 2.38it/s, loss=1.820]
Valid Epoch 0: 100%|██████████| 60/60 [00:08<00:00, 7.02it/s, loss=1.703]
Saving Model With Loss 1.91710
Train Loss: 1.96181 | Valid Loss: 1.91710 | Best Loss: 1.91710
Train Epoch 1: 100%|██████████| 60/60 [00:25<00:00, 2.39it/s, loss=1.847]
Valid Epoch 1: 100%|██████████| 60/60 [00:08<00:00, 7.02it/s, loss=1.957]
Saving Model With Loss 1.88124
Train Loss: 1.92786 | Valid Loss: 1.88124 | Best Loss: 1.88124
我們用資料集的第一筆資料來做生成和比對。
device = "cuda" if torch.cuda.is_available() else "cpu"
chat_input = formatted[0][:-1]
inputs = tokenizer.apply_chat_template(
chat_input,
return_tensors="pt",
padding=True,
truncation=True
).to(device)
outputs = model.generate(inputs, max_new_tokens=100)
texts = tokenizer.batch_decode(outputs, skip_special_tokens=True)
reply = texts[0].split(chat_input[-1]["content"])[-1].strip()
print(reply)
訓練前的模型回覆長這樣:
我們相聚的意義在於創造一個舒適的環境,讓我們一起享用一些美好的時候,例如聚會、旅行、學習、交朋友等。
訓練後的模型則是這樣說的:
意義在於經驗與成長。相聚是人與人之間交流、理解、影響的過程,其價值不在永恆,而在於曾經共同存在的時間留下了改變。
如莊子所言:「相濡以沫,不如相忘於江湖。」別離不消解意義,只是使它完成。
從這兩段可以很明顯看出,模型訓練前後在生成風格上有不小的差異。沒訓練過的模型說話比較直接,像是在描述一個很普通的情境,例如「創造一個舒適的環境」或「一起享用一些美好的時候」,語句通順但比較平淡,內容也偏表面。
而訓練過的模型,講話就不一樣了。不只是用上了「經驗」、「成長」這些抽象概念,還引用了莊子的話,把人際關係拉到哲學的層次去談,整體語氣更有深度,也更有人文氣息。這代表訓練之後的模型,不只是講話變得更有表達力,連理解語境、呈現價值觀的能力也都提升了不少。
今天我們聊的是一個模型是怎麼從 base 模型變成 chat 模型的第一步,也就是先經過 Instruction 微調,讓它能比較好地理解指令、做出像樣的回答。但其實這還只是開始而已。要讓一個 Chat 模型真正好用,還需要經過後續更複雜的調校流程——也就是我們稍早提到的 RLHF。不過 RLHF 這塊說實在不簡單,因為它牽涉到 "強化學習" 這類比較進階的概念。這次的系列文章不會深入講解這一部分,如果你真的很有興趣,推薦你去看這篇整理得很清楚的說明文:這篇文章。
而明天的內容會更延伸一下LLM的內容除了繼續帶你了解 Chat 版本模型,我也會教你怎麼用 base 模型來處理像是 Encoder 類型的任務。