[Day16]我們討論了本次賽題的兩種 baseline 的作法,一種是訓練 deberta
做 Multi-Class 或者是 Multi-Label 的任務;另外一種做法是找一個預訓練好的 LLM
不去訓練它,直接做 zero-shot,一樣是可以用 Multi-Class 或是 Multi-Label 兩種範式都可以。
(關於 "Multi-Class" 和 "Multi-Label" Task 的區別,可以參考[Day17] 文章末尾「小結」的部分。)
BERT-style 的Deberta
和 GPT-style 的 LLM
(如llama3-8b, Mistral-7b等等) ,兩者的差異除了在參數量的規模以外,最大的區別是前者是"encoder-only"的架構,後者則是 "decoder-only" 的架構設計,以及兩者在預訓練時使用的訓練任務和 attention 機制的差別。
(👆圖片來源)
BERT 的全稱是 Bidirectional Encoder Representations from Transformers,它的關鍵在於"Bidirectional",也就是「雙向」的意思,BERT的雙向注意力機制,使其能同時使用token前後的資訊來理解該個token的意義;而 GPT 的注意力是autoregressive (i.e. unidirectional) attention自回歸的(也就是單向的),這意味 GPT 只使用token前面的資訊來理解該 token 。
我們可以用下面的圖來理解這兩者的差異:
(👆🏻圖片來源)
除此之外,BERT 是透過 MLM(遮蔽語言模型)進行預訓練的,這個方法是將句子中的一個詞遮蔽起來,然後讓 BERT 使用句子的前後部分來推測這個被隱藏的詞;而 GPT 是透過 autoregressive language modeling 自回歸語言建模進行預訓練的。在這種方法中,模型被訓練來根據前面的上下文預測句子中的下一個詞。
(👆圖片參考自:來源1, 來源2)
不管是模型架構、注意力機制、預訓練選擇的任務,都影響 BERT 和 GPT 這兩種不同 style 的模型「理解與看待語言的方式」。
無論是 BERT-style 還是 GPT-style 的模型都各有其特色和優勢,目前因為效率、硬體資源限制等因素,目前主流的 LLM 都採用 decoder-only 的架構。
現在我們已經學會如何用 Wiki 建立外掛資料庫以及檢索的方法,也擴增了訓練資料集並學習如何用自己的資料集 fine-tune LLM,那麼現在我們有三種路線可以走:
(1). 結合外掛資料庫,用擴增訓練資料集訓練傳統的BERT-style(ex:Deberta)答題模型
(2). 結合外掛資料庫,用擴增訓練資料集訓練 GPT-Style (ex:一些常見的 LLM)的答題模型
(3). 結合外掛資料庫,用 zero-shot 或是 few-shot 以不改變模型權重的方式問 LLM 答案
每種路線,我們又可以選擇要用 "Multi-Class" 或是 "Multi-Label" 的範式來構建我們的 model input/output。
❓❓各位可以猜猜看,哪一種路線被廣泛應用於本次賽事的金牌解法中呢❓❓
前幾天的實驗已經基本確定這次比賽就是要用「開書考」的方式進行才能拿高分,因此不管是用上面哪一種路線開發,每個解法的基本架構都會長得像下圖這樣:
這張圖有三個主要的 Module:
整題的流程就是:
解法百百種,但其實每個組別都是在想辦法優化三個環環相扣的 Module(Database, Retriever, Answer Model)的設計,以提升最後 答題的準確率。
我們一樣從 Database, Retriver, Answer Model 三者的設計來還原第一名的作法。
1. Database
一開始他們跟大部分的參賽者一樣,使用public wiki這個資料集當作他們參考資料以及參考資料embedding的來源(這個資料集包含 wiki 大部分的文章段落、標題,以及這些文本用 all-MiniLM-L6-v2
轉出來的向量)。
但是他們發現這份公開的維基百科數據集未能正確展開經常用於科學文章的 Lua
代碼,這導致上下文不完整。為了處理這一問題,他們決定使用 CirrusSearch
的數據集。
CirrusSearch
數據集是近乎完整渲染的維基百科頁面的快照,從而確保上下文中的科學數據被正確解析。不過這個數據集也有一些缺點--它缺少換行符。於是第一名的團隊將這個數據集中的句子合併成不同的目標長度(256、512 或 1024 個字符),同時避免打斷句子。最後實驗下來發現合併成 512 個token 為一個 context chucnk 的效果是最好的。
另外他們也手動抓一些離比賽日期比較近的 wiki page,構建一個比較新的 wiki context dataset。
總之,他們花費大量的時間構建一個有 60M 個 context chunk 的龐大 wiki context dataset,並使用gte-large
和e5-large
這兩種 embedding model,分別把 wiki 文章的 title encode 成 embedding 後,再和對應文章的 context chunk embedding 相加在一起,組成該個 context chunk 的 embedding。所以他們有兩個 embedding model 版本的 context embedding。
另外,他們的 dataset 裡面不只有 science-based 主題的 context ,也包含其他主題的內容。原因是他們一開始只選用和 science-based 相關的資料來構建 database,但後來發現這樣效果不如涵蓋各式各樣的內容到 database ,對之後的 Retriever 檢索和 Answer Model 訓練會有比較好的效果。
對他們構建的 wiki dataset 感興趣的朋友,可以到這個連結下載他們做好的 wiki dataset。
2. Retriever
MTEB Leaderboard是一個專門測試 text embedding model 的英雄榜,我們根據這個榜單上面的任務(例如:Classification, Reranking),以及語言(EX: 英文、中文),挑選最適合當前任務的 embedding model。
第一名的團隊從榜單上挑選 top20 的embedding model,在固定 answer model 的前提下,測是哪一個 embedding model retrieve 回來的 context 最能幫助 answer model 正確地回答問題。
最後他們選擇使用e5-base-v2
, e5-large-v2
, gte-base
, gte-large
和 bge-large
這五個 embedding 模型,將問題和選項以下面兩種方式 encode 成向量:
1.“{prompt} {A}, {B}, {C}, {D}, {E}”
2. “{prompt} {A}” or “{prompt} {B}”(一次只有一個問題搭配一個選項)
用不同的 encode 方式轉換出多個 query embedding 增加多樣性(防止之後 answer model 在訓練中 Overfit 在某個局部特徵),之後再去和 database 的 context embedding 計算相似度,找出 topk 相關的參考資料。
由於這邊 wiki context embedding 實在太多了,為了高效計算,我們要用 GPU 計算 query embedding 和 context embedding 的 cosine similarity。
具體要怎麼做,我們上代碼!
主要的流程如下:
- 載入 Wikipedia 資料的 embedding。
- 將問題及選項轉換成 embedding。
- 計算問題 embedding 和 Wikipedia 資料的相似度。
- 找出最相似的 Wikipedia 資料段落作為上下文。
- 儲存這些上下文,供後續模型推理使用。
首先我們先定義一些主要的 function:
cos_similarity_matrix(a, b)
def cos_similarity_matrix(a: torch.Tensor, b: torch.Tensor):
"""計算張量 a 和 b 之間的餘弦相似度。"""
sim_mt = torch.mm(a, b.transpose(0, 1)) # 計算 a 和 b 的矩陣乘積
return sim_mt
get_topk(embeddings_from, embeddings_to, topk=1000, bs=512)
def get_topk(embeddings_from, embeddings_to, topk=1000, bs=512):
chunk = bs # 每次處理 512 條數據
embeddings_chunks = embeddings_from.split(chunk) # 將 embeddings_from 分成小塊
vals = [] # 儲存 topk 相似度的值
inds = [] # 儲存對應的索引
for idx in range(len(embeddings_chunks)):
# 計算這一塊和 embeddings_to 的相似度
cos_sim_chunk = cos_similarity_matrix(
embeddings_chunks[idx].to(embeddings_to.device).half(), embeddings_to
).float()
cos_sim_chunk = torch.nan_to_num(cos_sim_chunk, nan=0.0) # 防止 NaN 值
topk = min(topk, cos_sim_chunk.size(1)) # 確保 topk 不超過資料的大小
vals_chunk, inds_chunk = torch.topk(cos_sim_chunk, k=topk, dim=1) # 選出 topk 相似度最高的
vals.append(vals_chunk[:, :].detach().cpu()) # 儲存相似度值
inds.append(inds_chunk[:, :].detach().cpu()) # 儲存對應的索引
vals = torch.cat(vals).detach().cpu() # 合併所有塊的結果
inds = torch.cat(inds).detach().cpu()
return inds, vals # 返回最相似的 topk 段落的索引和相似度值
embedding_from
是從 wiki context embedding dataset load 近來的一份 embeddings file,因為這個檔案來是太多向量了,所以按照 512 個為一組,先切成大塊大塊的 chunk,將這些 chunk 都轉成 torch tensor 後放到 gpu 上,和 query embedding(也就是 embedding_to
)計算相似度。
整體的流程如下:
因為我們沒辦法一次針對 database 所有 context embedding 計算,需要先維護一個 global topk list,之後再分檔案、分次找到每個 file的 topk後 ,再更新 global topk list。
所以我們逐個讀入 database 中的每個 files,然後將 files 內的 embedding 轉為 tensor 後移入指定的 gpu;接著用上面的 get_yopk()
找到這個 file 的 topk 後,再利用update_top_k_lists
這個 function 去更新 global 的 topk list。
由於檔案太大太多,我們把 Database 的檔案拆成兩半,找兩個 GPU 一人一半平行處理,所以每個 GPU 我們都要維護一個 top k list:
all_vals_gpu_0 = torch.full((len(test), TOP_K), -float("inf"), dtype=torch.float16)
all_vals_gpu_1 = torch.full((len(test), TOP_K), -float("inf"), dtype=torch.float16)
接下來用下面 load_data
function,在每個 GPU 上進行獨立計算:
def load_data(files, device):
"""從指定檔案載入資料,並對問題 embedding 進行相似度計算,返回最相似的段落。"""
for file, file_np in files:
# 1. 讀取 Wikipedia 文本資料及其 embedding
df = pd.read_parquet(file, engine="pyarrow", use_threads=True) # 讀取 Wikipedia 的文本資料
file_embeddings = np.load(file_np) # 讀取 embedding
# 2. 將 embedding 轉換為 tensor 並移動到指定的 GPU
data_embeddings = torch.Tensor(file_embeddings).to(device).half() # 轉換為半精度浮點數 tensor
data_embeddings = torch.nn.functional.normalize(data_embeddings, dim=1) # 標準化
# 3. 計算問題 embedding 與 Wikipedia embedding 的相似度,返回最相似的段落
max_inds, max_vals = get_topk(query_embeddings, data_embeddings, topk=TOP_K, bs=8)
# 4. 對每個問題更新最相似段落的列表
for i in range(len(test)):
if device == "cuda:0":
update_top_k_lists(i, max_inds, max_vals, df, all_vals_gpu_0, all_texts_gpu_0)
else:
update_top_k_lists(i, max_inds, max_vals, df, all_vals_gpu_1, all_texts_gpu_1)
def update_top_k_lists(query_index, max_inds, max_vals, df, all_vals, all_texts):
"""根據相似度值,更新指定 query 的最相似段落列表。"""
for new in range(TOP_K):
# 如果當前的相似度低於列表中最小的相似度,則跳過
if max_vals[query_index][new].item() < all_vals[query_index][TOP_K - 1]:
break
# 找到合適的位置,插入新的相似度值與對應段落
for old in range(TOP_K):
if max_vals[query_index][new].item() > all_vals[query_index][old]:
all_vals[query_index] = insert_value_at(
all_vals[query_index],
value=max_vals[query_index][new].item(),
position=old,
)
all_texts[query_index] = insert_value_at_list(
all_texts[query_index],
value=df.iloc[max_inds[query_index][new].item()].text,
position=old,
)
break # 插入後停止內層循環
我們用兩個 GPU 去平行計算 Database 所有 context 和 test data 裡面 query 的相似度。
from joblib import Parallel, delayed
Parallel(n_jobs=2, backend="threading")(
delayed(load_data)(files[i], f"cuda:{i}") for i in range(2)
)
都算完之後,我們要整合兩個 GPU 的資訊,merge 到唯一的 global topk list 裡面,來要記得把重複的 text 刪掉:
all_vals = torch.hstack([all_vals_gpu_0, all_vals_gpu_1])
val, inds = torch.topk(all_vals.float(), axis=1, k=TOP_K)
all_texts = [
[(t0 + t1)[inner_idx.item()] for inner_idx in idx]
for t0, t1, idx in zip(all_texts_gpu_0, all_texts_gpu_1, inds)
]
all_texts = [remove_consecutive_duplicates(lst) for lst in all_texts]
合併去重複後剩下的 topk context,就會組成上圖中的 enhanced context 傳給 answer model。
檢索段落的影響
在一開始的 pilot study 中,他們只 retrieve 1 個最相關的 context 給 answer model,但很快他們就發現,隨著 retrieve 的段落增加, answer model 的準確度也會上升,但是太多段落會導致訓練速度變得太慢,因此他們採用兩種 train/inference asymmetry 的策略--
也就是在訓練的時候,只 retrieve 3 個最相關的段落傳給 answer model;但是在 Inference 的時候,會 retrieve 5 個段落來增加上下文的資訊。
檢索噪音的影響
另外一件有趣的事情是,他們發現在 Answer Model 的 training 階段,如果不要每次都選分數最高的前三個 context 當作參考資料,而是加上一些隨機擾動,例如隨機從top30~top100的context中,隨機抽取一些組成該筆訓練資料的 enhanced context,反而可以起到一種類似 "regularization" 的作用,讓 answer model 在測試階段時,遇到很困難的題目、找不到那麼相關的參考資料時,模型也能自己判斷找出正確選項。這個發現,在第18名的報告中也有提到~
3. Answer Model
他們的 Answer Model 選擇 ensemble Llama-2-7b
、Mistral-7B-v0.1
、xgen-7b-8k-base
和 Llama-2-13b
這四個模型的結果。
每個模型都使用 LoRA 加在 linear layer 做 fine-tuned;並且他們把問題和選項轉成 “Multi-Label” 的形式,也就是說每個 input 都是一個問題配上一個選項,模型要回答的是 Yes or No,一個題目搭配不同選項,總共會問模型五次;
最後他們還使用一個單獨的二元分類頭來對llm最終的預測next token logits 進行分類。
另外,在他們的實作細節中,他們還設計兩件很巧妙的事情:
因為每一題的五個選項前面都是一樣的問題和參考資料,為了節省編碼的時間,推理過程中,模型首先對問題和上下文進行編碼,並將每個選項的 past_key_values
作為 cache,這樣每次只需要額外前向傳播「選項」的部分即可,不用每次都重複編碼問題和參考context。
如果要更了解這部分的實現,可以搜尋 KV cache,或是我之後也會寫一篇相關文章,之後會在這邊更新連結~
因為現在採用 "Multi-Label" 的方式 formulate 給 LLM 的問題,代表 answer model 一次只會看到問題和一個選項,他沒辦法結合不同選項來決定答案。但我相信大家應該都有在考試的時候,不知道正確答案是什麼,沒關係,我們就用刪去法的這種經驗吧! 或者我們可以觀察其他選項,例如其他選項都是奇數、只有一個是偶數,或是只有一個選項有某種性質和其他幾個選項差異很大,此時我們就會選擇該個選項。
但如果一次只能看到一個選項,就沒辦法用這種答題技巧了;但是如果捨棄 Multi-Label 改成 Multi-Class 的形式(也就是一次給五個選項,要模型從中挑一個正確答案),模型很容易受到選項順序影響它最後輸出的答案,例如模型可能傾向輸出它第一個看到的選項(A),或是最後一個看到的選項(E)。(看來我們的模型也不是特別智能嘛!)
雖然 Multi-Class 的這種 "order sensitive" 的問題也是有辦法克服的,這邊作者還是堅持採用 "Multi-Label" 的方式,但是他們使用 "cross-information" 的技巧。
假如現在我們想知道選項 A 是正確答案的機率有多少,我們就詢問 LLM: 「選項(A)是正確答案嗎?回答 Yes/No」,經過一陣計算後,我們可以提取模型的 final next token 的 logits,也就是模型預測 next token 在詞彙表上所有 token 的機率分佈,這邊我們稱為 target_logits
。
為了要讓最後的決策可以參考到其他選項的資訊,我們也依序問模型「選項(B)是正確答案嗎?回答 Yes/No」、「選項(C)是正確答案嗎?回答 Yes/No」、「選項(D)是正確答案嗎?回答 Yes/No」、「選項(E)是正確答案嗎?回答 Yes/No」,然後存下每個選項的 "final next token" 的 logits,再把這些四個問題的全詞彙表 token logits 分佈取平均,得到 other_logits
。
最後一步就是把 target_logits
和 other_logits
接在一起變成 [2*vocabulary size] 的向量,輸入給一個 classify linear layer,讓它根據 target_loagits 和 other_logits 預測到底「選項A」是正確答案的機率為何。
如果用文字講起來不夠清楚的話,可以看下圖:
作者說加入這步驟後明顯提升 performance,我想classifier應該是在訓練的過程中,學會分辨 concatenate 後的向量前半部分是目標選項的 logits 分佈,後半部分則是其他選項的 logits 分佈。
如果目標選項的 logits 分佈中,很明顯 "Yes" 的分數很高,而 other logits 平均之後 "No" 的分數很高,模型應該會有表較高的信心判定目標選項應該是正確答案。
整個作法在 LB 可以拿到 0.9366 的分數!
完整代碼可以參考:第一名inference code,他們的 write-up 可以參考這邊~
同樣,他們也是發現常見的 wiki dataset 缺乏不少 page,並且移除像是 <math></math>
這些對於 science-base 文章來說很重要的資訊,所以他們用 wikitextparser
加上他們自己寫的custom template processing,保留 <math>
, <val>
等tag內的資訊,但是移除 <table>
, <ref>
等比較不相關的資訊,維持 database 的乾淨。
他們使用 Sparse 與 Dense 兩種 Retrieval 策略:
作者受到 ORQA 這篇論文的啟發,裡面提到 "在一些數據集上,傳統的 IR 系統(如 BM25)已經足夠。" 基於這一點,他們選擇了 BM25 作為他們的其中一種檢索技術。
他們將整個 Wikipedia 資料集劃分為段落(以 "\n\n" 作為分割符,約產生 7400 筆 records),並使用 Pyserini 對這些資料進行 BM25 算法的 index,這大概只需要幾個小時就能完成。實際在檢索時,他們採用多線程的方式加速,大概能在 2 分鐘內完成 200 次的檢索。
總之,他們用 BM25 的算法,在自己做出的 wiki dataset 先檢索 topk 相關的 context 出來。
instructor-xl
和 bge-large-en
,前者 encode 他們做好的 wiki 資料庫,後者 encode 另外一個 STEM270k 資料集裡的 context,用這兩種方式各自找到 topk 的相關資料。之後會把這三種方法找到的 topk referecne context 組合起來,一起送入 answer model。
他們主要使用 Mistral 7B
和 Llama-2-70B
這兩種沒有經過 instruction-tuning 的 pretrained llm 作為他們 answer model 的主體。
{context_0}
Question: {prompt}
A. {A}
B. {B}
C. {C}
D. {D}
E. {E}
Answer:
但還記得我們有提到 "Order Sensitive" 的問題嗎?也就是模型會因為選項擺放的順序不同,而影響它最後輸出的答案。
為了避免這個問題,他們採用 TTA(Test Time Augmentation) 的策略,就是把'ABCDE'往左 shift 五次,變成這樣:
{context} {Q} {A B C D E} {B C D E A} … {E A B C D}
雖然要花更多時間 inference,但他們實測下來發現,這種 TTA 作法可以在一定程度上緩解模型的 order bias
微調技術:QLoRA
當然,他們也是有微調 LLM 的。他們用 QLoRA 將模型量化到 4bits ,並且採用和 QLoRA 那篇paper 一樣的參數來微調模型,唯一改變的只有 batch size 而已。
多階層 Hierarchical Inference 策略
因為 70B 的模型所需的推理時間實在是太久,並且 Retrieve 到的段落越多,就需要越久的時間計算答案;可是如果只 retrieve 很少的 reference context ,又會影響 Performance。
到底該怎麼辦呢?
他們想到一個**「對症下藥、按需分配」**的策略。
每個模型都能計算選項 A, B, C, D, E 這五個 token 的 predicted token probability,我們可以再針對這五個機率值做一次 softmax,當作模型對每個選項是否為正確答案的預測機率。
所以現在每個問題都有兩種方式預測出來的答案,我們把這些答案平均起來。
如果有題目被模型預測出來的最有可能的答案,其機率值(這邊我們叫 confidence) 很低,那有可能就是這題比較難,模型對它預測出來的結果比較沒信心。
這時候我們就把這些「難題」挑到下一個階段,用更複雜的模型再做一次預測;其餘的題目就是簡單的,我們不需要再做更多預測了,從第一階段的結果就可以得到最終的預測答案。
這就叫做多階層推理策略。
階段2: llama2-70B
這個階段就是挑出階段1中信心值排在末段40%左右的那些題目,上更大的模型->llama2-70B,然後用 BM25 找 top2 的參考資料,再用instructor and bge embedding model 找兩個 context 出來預測。
同樣的,我們把信心值排在後段 5% 左右的題目再挑檢出來,交到階段 3 預測。
階段 3: 還是用 llama2-70B
但是用 bge embedding model 挑出比前面兩個階段來要多三倍的參考資料給模型預測最終答案!
整體架構如下:
最終在 LB 上取得 0.9262 的成績!
Zero-Shot LLM 這種路線,在本次比賽最好的排名是 56 名左右,基本上要近金牌區都需要 fine-tune answer model。
在分析所有 Zero-Shot的作法後,我發現大部分這個路線的解法,都使用 bge-small-en-v1.5
或是 all-MiniLM-L6-v2
這兩個 embedding model 來 encode wiki context 與 query context;另外,如果想要有不錯的表現,除了要建構一個龐大的 Wiki Database 來當作檢相關資料的來源外,answer model 至少也要選擇 70B 以上的模型,才會有不錯的效果。
到 70B 這個規模的模型,就需要用一些特別的處理來 load 這麼龐大的模型,並加速他的計算。
其中一種做法就是 layer-wise 的方法,有興趣的朋友可以參考我在[Day16]的說明。
另外大部分的人都是用 Multi-Label 的方式來 formulate 給 LLM 的問題,下面提供一個以 Multi-Label 形式輸入給 model 的 prompt format 給大家參考:
system_prefix = "Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.\n\n### Instruction:\n{instruction}\n\n### Input:\n{input_prefix}"
instruction = "Your task is to analyze the question and answer below. If the answer is correct, respond yes, if it is not correct respond no. As a potential aid to your answer, background context from Wikipedia articles is at your disposal, even if they might not always be relevant."
input_prefix = f"Context: {row['context'][:MAX_CONTEXT]}\nQuestion: {row['prompt']}\nProposed answer: "
prompt_prefix = system_prefix.format(instruction=instruction, input_prefix=input_prefix)
prefix = tokenizer(prompt_prefix, return_tensors="pt", return_attention_mask=False, truncation=True, max_length=MAX_LENGTH)["input_ids"]
prompt_suffix = [f"{row[letter]}\n\n### Response:\n" for letter in "ABCDE"]
Zero-shot 70B LLM 的作法,在 LB 可以拿到 0.89 的分數,也是非常不錯的呢!
如果是你,會選擇哪一種作法呢 (✪ω✪)
今天介紹使用兩種 Decoder-only 架構的模型作為 answer model 的解法、fine-tune 與 zero-shot answer model 在 Leaderboard 上得分的差異,還有整體RAG系統的組件與互動流程。
明天會繼續介紹 encoder-only 架構的 answer model 其整體的流程,以及這種做法需要思考的事情!
我們明天見~