iT邦幫忙

2024 iThome 鐵人賽

DAY 16
1
AI/ ML & Data

一個Kaggle金牌解法是如何誕生的?跟隨Kaggle NLP競賽高手的討論,探索解題脈絡系列 第 16

[Day 16]輕量級模型能否在複雜科學問題上追平ChatGPT呢?- OOM了怎麼辦?淺談 LLM 分層加載技術(layer-wise loading)、Perplexity 與 RAG 策略

  • 分享至 

  • xImage
  •  

在霓虹閃爍的賽博朋克城市深處,隱藏著一個不為人知的秘密競技場。這裡不再有血肉之軀的戰鬥,而是智能模型之間的對決。

在這個地下世界里,參賽者們扮演著勇敢的戰士,他們攜帶自己開發的輕量級AI模型進入戰場,就像索妮Sonnie駕馭她的Khanivore怪獸一樣。而對手則是強大的ChatGPT,它如同Turboraptor般龐大且充滿力量,代表著當前大語言模型技術的巔峰。

image
(自《愛x死x機器人》 - Sonnie’s Edge 索尼的優勢重新做圖)

賽題說明

背景介紹

今天要介紹的是 2023 年7月舉辦的 Kaggle 上首個關於大語言模型 LLM 的競賽 - Kaggle - LLM Science Exam,共為期 3 個月。

一兩年前人們有問題都會用谷歌打關鍵字搜尋答案;現在很多人遇到疑難雜症,都是打開 chatgpt 一邊跟他聊天一邊從他的回答中尋找答案。一般情景 ChatGPT 都能表現得不錯,但如果問的是有關科學、數學等專業知識,這些 LLM 的表現又會如何呢?

這次主辦方通過從維基百科Wiki提取的一系列科學主題,再交由 ChatGPT(gpt-3.5)根據這些主題生成單選題(帶有已知答案),再過濾掉一些簡單問題後,建構了一個大約有 4400 多個(選擇題, 答案)的科學主題資料集。其中 400 筆資料是公開給參賽者的訓練和測試資料,剩下 4000 多筆資料會成為參賽者看不到的測試資料集。當然,建構這個資料及使用的 prompt 與參考文本等等都是不會公佈的。

比賽的目標是要讓我們自己訓練的輕量級(Kaggle有硬體限制)模型,可以在這份由 chatgpt 出題的考卷上,盡可能地拿高分!

目前估計 Kaggle 上運行的最大模型約有 100 億個參數,而 gpt3.5 的參數為 1750 億個。如果我們開發的輕量級問答模型能夠在由比其規模大 10 倍的模型所編寫的題目中表現出色,這會是一個很有趣的結果,這其實就是蒸餾模型 Knowledge Distillation 一直在研究的議題。

資料集介紹

這次Host有提供 train.csv 和 test.csv,各有 200 筆資料,每筆資料都有以下的欄位:
prompt - 提問的文本內容
A - 選項 A;如果此選項正確,答案將是 A
B - 選項 B;如果此選項正確,答案將是 B
C - 選項 C;如果此選項正確,答案將是 C
D - 選項 D;如果此選項正確,答案將是 D
E - 選項 E;如果此選項正確,答案將是 E
answer - 生成的 LLM 所定義的最正確答案(A、B、C、D 或 E 之一)。

我們來淺看一下訓練資料:
image

挑幾題簡單一點的題目來看看我們答不答得出來:

第一題如下,請接招!

問題1:What was Pierre de Fermat's solution to the problem of refraction?(翻譯:皮埃爾·德·費馬對折射問題的解答是什麼?)
A: Fermat supposed that light took the path of least resistance, and that different media offered the same resistance. His eventual solution, described in a letter to La Chambre dated 1 January 1662, construed "resistance" as inversely proportional to speed, so that light took the path of least time. That premise yielded the ordinary law of refraction, provided that light traveled more slowly in the optically denser medium.(翻譯:費馬假設光會走「最小阻力」的路徑,並認為不同的介質提供相同的阻力。他最終的解答是在1662年1月1日給拉尚布爾的一封信中描述的,將「阻力」解釋為與速度成反比,這樣光就會走「最短時間」的路徑。這一前提推導出了折射的普通定律,前提是光在光學上密度較大的介質中傳播得更慢。)

B: Fermat supposed that light took the path of least resistance, and that different media offered different resistances. His eventual solution, described in a letter to La Chambre dated 1 January 1662, construed "resistance" as directly proportional to speed, so that light took the path of least time. That premise yielded the ordinary law of refraction, provided that light traveled more quickly in the optically denser medium.(費馬假設光會走「最小阻力」的路徑,並認為不同的介質提供不同的阻力。他最終的解答是在1662年1月1日給拉尚布爾的一封信中描述的,將「阻力」解釋為與速度成正比,這樣光就會走「最短時間」的路徑。這一前提推導出了折射的普通定律,前提是光在光學上密度較大的介質中傳播得更快。)

C: Fermat supposed that light took the path of least resistance, and that different media offered the same resistance. His eventual solution, described in a letter to La Chambre dated 1 January 1662, construed "resistance" as directly proportional to speed, so that light took the path of least time. That premise yielded the ordinary law of refraction, provided that light traveled more slowly in the optically denser medium.(費馬假設光會走「最小阻力」的路徑,並認為不同的介質提供相同的阻力。他最終的解答是在1662年1月1日給拉尚布爾的一封信中描述的,將「阻力」解釋為與速度成正比,這樣光就會走「最短時間」的路徑。這一前提推導出了折射的普通定律,前提是光在光學上密度較大的介質中傳播得更慢。)

D:Fermat supposed that light took the path of least resistance, and that different media offered the same resistance. His eventual solution, described in a letter to La Chambre dated 1 January 1662, construed "resistance" as inversely proportional to speed, so that light took the path of least time. That premise yielded the ordinary law of refraction, provided that light traveled more quickly in the optically denser medium.費馬假設光會走「最小阻力」的路徑,並認為不同的介質提供相同的阻力。他最終的解答是在1662年1月1日給拉尚布爾的一封信中描述的,將「阻力」解釋為與速度成反比,這樣光就會走「最短時間」的路徑。這一前提推導出了折射的普通定律,前提是光在光學上密度較大的介質中傳播得更快。

E:Fermat supposed that light took the path of least resistance, and that different media offered different resistances. His eventual solution, described in a letter to La Chambre dated 1 January 1662, construed "resistance" as inversely proportional to speed, so that light took the path of least time. That premise yielded the ordinary law of refraction, provided that light traveled more slowly in the optically denser medium.(費馬假設光會走「最小阻力」的路徑,並認為不同的介質提供不同的阻力。他最終的解答是在1662年1月1日給拉尚布爾的一封信中描述的,將「阻力」解釋為與速度成反比,這樣光會走「最短時間」的路徑。這一前提推導出了折射的普通定律,前提是光在光學密度較大的介質中傳播得更慢。)

答案是:E

剩下的問題就不放選項了,因為都滿長的怕大家往下滑得很辛苦xdd
我們就看看問題就好:

問題2: Which mathematical function is commonly used to characterize linear time-invariant systems?(哪個數學函數通常用來描述線性時不變系統?)
問題3: How many crystallographic point groups are there in three-dimensional space?(在三維空間中有多少個晶體學點群?)
問題4: Who was the first person to describe the pulmonary circulation system?(誰是第一個描述肺循環系統的人?)
問題5: How do the Lunar Laser Ranging Experiment, radar astronomy, and the Deep Space Network determine distances to the Moon, planets, and spacecraft?(月球激光測距實驗、雷達天文學和深空網絡是如何測定到月球、行星和航天器的距離的?)

仔細看看這些問題,會發現好像大部分都是和物理相關的題目,偶爾會有一些生物、化學主題的題目。

不知道這些問題對你是簡單還是困難呢?

本人大一物理學得很差勁,但剛好有一個學物理的男友,考完他之後發現他大部分都答對,題目大概是大一物理就可以輕鬆答對的程度。

我們先來初步分析一下已有的資料集,會發現所有問題都是以6W1H開頭的,我們來統計一下每個疑問詞都各自有幾個問題:

6W1H train.csv test.csv
What 180/200 180/200
Which 11/200 11/200
Who 7/200 7/200
How 2/200 2/200
When 0/200 Text
Why 0/200 Text
Where 0/200 Text

絕大多數問題都是在考“事實型”的問題,就感覺是那種老師畫重點之後,大家有背有分,如果開書考一定大家都滿分的的那種題目,沒有什麼需要推理的問題。所以如果可以有比較高效的方法 encode 大一普物、普生、普化等觀念、人名、重要事件等資訊,再讓模型基於 RAG 的方法生成答案,應該就足以回答這些問題了。

另外我們可以發現有3個W是沒有任何問題的,這很有可能是隱藏的測試資料集會出現的問題。最需要小心的大概是 "Why",因為 "Why" 開頭的問題很有可能是需要模型進行多跳(Multi-Hop)推理才有辦法回答的問題,而不是像前面展示的那種上網查資料或是把課本背下來就能回答出來的問題。不過我們也不曉得測試資料會不會有這類型的問題就是了。

評估指標介紹

介紹完 dataset,我們來看看這個比賽使用的評估指標是Mean Average Precision @ 3 (MAP@3),這是一種用於多標籤分類問題的精度評估指標。它通過計算每個問題的前三個預測結果中的精度來評估模型的表現:
image

U:測試集中問題的總數,代表需要預測的問題數。
P(k):在第 𝑘 個位置的精度(Precision),即在前 𝑘 個預測中正確答案出現的比例。
精度計算方式:如果在前 𝑘 個預測中有 𝑥 個正確答案,則精度 𝑃(𝑘)= 𝑥/𝑘
n:每個問題的預測數量,最多計算前 3 個預測。
rel(k):指示函數,若第 𝑘 個預測是正確答案,則值為 1,否則為 0。
我們來舉一些例子看看:
例子 1: [A, B, C]
正確答案是 A。

  • 第 1 個預測 (A):這是正確答案,精度
    P(1) = 1/1 = 1.0,rel(1) = 1。
    已經找到一個正確答案了,所以剩下的預測就不用再看。
    總計:
    MAP@3 = (1 × 1) = 1.0

例子 2: [A, A, A]
正確答案是 A。

  • 第 1 個預測 (A):這是正確答案,精度
    P(1) = 1/1 = 1.0,rel(1) = 1。
    後續預測:因為 A 已經在第 1 個預測中正確,後續的 A 預測被跳過。
    總計:
    MAP@3 = (1 × 1) = 1.0

例子 3: [B, C, A]
正確答案是 A。

  • 第 1 個預測 (B):這是錯誤答案
    P(1) = 0/1 = 0, rel(1) = 0
  • 第 2 個預測 (C):這是錯誤答案
    P(2) = 0/2 = 0, rel(2) = 0
  • 第 3 個預測 (A):這是正確答案
    P(3) = 1/3 = 1/3, rel(1) = 1
    MAP@3 = (1/3 × 1) = 0.33

目前為止,你已經了解比賽的目標、資料的數量及其 input 與 output 的格式了,還有評分所使用的 metric 了。


❓❓可以暫停一下,思考看看,如果你是參賽者,你會如何設計你的第一個解題方案呢?你的第一步是什麼❓❓


在開始開發任何 solution 之前,我們還是可以先用 LB Probing 的方式,先探索一下 Train vs. Test set 的差距。

從 Perplexity 探索 Train vs. Test set 的差距

我們先來解釋一下 Perplexity 困惑度 的概念。

💡 Perplexity 的概念

Perplexity(困惑度) 是一個衡量語言模型對序列預測質量的指標,它反映了模型對下一個 token 預測的「不確定性」。具體來說,困惑度越低,表示模型對下一個 token 預測得越好,對該序列的理解也越準確。

在語言模型中,模型的任務是基於當前已知的上下文預測下一個 token。困惑度的計算基於模型對每個 token 的預測(logits)和實際的下一個 token(labels)之間的損失值,通常使用交叉熵損失來計算。

  • 例子解釋:
    假設你有一個語言模型,它的任務是根據一個問題生成一個答案。困惑度告訴你模型對某個答案的信心有多高。例如,給定一個問題和五個可能的選項(A、B、C、D、E),模型可以預測每個選項作為正確答案的可能性。

    困惑度實際上是基於這些預測的損失值(Loss),用來衡量模型對每個選項的信心:

    • 困惑度:表示模型對答案的預測具有較高的信心。模型認為這個答案的可能性較大,因為它能根據前面的內容正確預測出後面的 token。
    • 困惑度:表示模型對答案的預測較不確定,模型在生成該答案時遇到了困難。

我們前面不是觀察到提供的公開資料只有包含 3個W 1個H 的問題,且都是事實型的問題。但不知道在 public test set 和 private test set 會不會有一些更難的問題,例如推理型問題,或是更困難的事實型問題。為了瞭者兩種 dataset 的差異,網友@PSI提供了一個有趣的小實驗,這個實驗的目的是觀察和比較 training data 和 testing data 的難度差異,通過使用一個固定的 LLM 模型來預測每一題的答案,分析這兩個數據集的 performance,這邊是指 MAP@3 指標的分數。

具體來說,作者想了解測試數據集(testing data)是否比訓練數據集(training data)難得多,從而導致預測性能的顯著下降。

他的實驗方法如下:
1. 固定的 LLM 模型:

作者選擇了一個固定的 LLM(大語言模型),這個模型用來同時在訓練數據集和測試數據集上進行答案預測,確保模型本身在兩個數據集上的行為一致,避免模型的變量影響結果。

2. 困惑度(Perplexity)計算:

對於每個問題,作者將問題文本與每個選項拼接成「問題 + 選項」的形式,然後通過 LLM 模型計算每個選項的困惑度(perplexity)。困惑度越低,表示模型對該選項的信心越高。

3. 答案預測:

對於每個問題,模型根據選項的困惑度進行排序,選擇困惑度最低的選項作為預測的答案。

4. 在訓練數據集上的評估:

先在 training data 上使用相同的方法進行預測,並將預測結果與真實答案(ground truth)進行比較,從而得到在訓練數據集上的 MAP@3 score。

5. 在測試數據集上的預測和評估:

接下來,使用同一個模型和相同的困惑度計算方式,在 testing data(測試數據集)上進行預測。測試數據集的結果被提交到 leaderboard(排行榜),得到在測試數據集上的 MAP@3 score。

6. 比較訓練數據和測試數據的表現:

通過比較訓練數據集和測試數據集的 MAP@3 scores,作者可以判斷兩者的難度差異。如果在測試數據集上的分數顯著低於訓練數據集,則表示測試數據集的問題要比訓練數據集困難得多。

具體的作法我們可以看看他寫的 code(reference to 1),非常簡潔直得我們學習🤩:

  • 載入模型,這邊他選擇的 pretrained llm 是 h2oai/h2ogpt-gm-oasst1-en-2048-open-llama-7b,llama-7b based 的模型:
tokenizer = AutoTokenizer.from_pretrained(
    llm_backbone,
    use_fast=False,
    trust_remote_code=True,
    padding_side="left"
)
model = AutoModelForCausalLM.from_pretrained(
    llm_backbone,
    torch_dtype=torch.float16,
    device_map="cuda:0",
    trust_remote_code=True,
)
  • 計算並比較各選項的困惑度::
for idx, row in tqdm(test.iterrows(), total=len(test)):
    with torch.no_grad():
        cols = ["A", "B", "C", "D", "E"]
        perps = []
        samples = []
        for col in cols:
            samples.append("<|prompt|>"+row["prompt"]+"</s><|answer|>"+row[col])
        inputs = tokenizer(samples, return_tensors="pt", add_special_tokens=False, padding=True, truncation=True).to("cuda")

        output = model(input_ids=inputs["input_ids"], attention_mask=inputs["attention_mask"])
        output = output.logits
        labels = inputs["input_ids"]
        labels.masked_fill_(~inputs["attention_mask"].bool(), -100)
        for j in range(len(cols)):
            p = perp(output[j].unsqueeze(0), labels[j].unsqueeze(0))
            perps.append(p.detach().cpu())

我們來仔細看上面的 code。

samples.append("<|prompt|>"+row["prompt"]+"</s><|answer|>"+row[col])

首先他先把問題 prompt 和每個選項拼接起來,並且在選項前面加上 |answer|的 token,假裝這個選項是答案,所以五個選項就會有 5 個這樣拼接好的 string。如果模型會這題並且剛好拼接的選項是錯誤答案,模型讀到這邊就會覺得怪怪的,明明|answer|後面接的就不是答案,這時候困惑度理想上就會提高。

接下來,我們把這些拼接好的句子輸入到模型,做一次 forward pass:

output = model(input_ids=inputs["input_ids"], attention_mask=inputs["attention_mask"])
output = output.logits

這裡的 logits 是模型對每個 token 的預測結果,形狀類似於 [batch_size, seq_length, vocab_size],表示模型對每個輸入序列中每個單詞的預測分布。

接下來的關鍵步驟是計算損失,這個損失就是用來計算困惑度的基礎。作者通過自定義的 Perplexity 模塊來計算每個選項的損失值。

class Perplexity(nn.Module):
    def __init__(self, reduce: bool = True):
        super().__init__()
        self.loss_fn = nn.CrossEntropyLoss()  # 使用交叉熵損失
        self.reduce = reduce

    def forward(self, logits, labels):
        shift_logits = logits[..., :-1, :].contiguous()  # 調整 logits 和 labels 的位置
        shift_labels = labels[..., 1:].contiguous()

        perplexity = []
        for i in range(labels.shape[0]):
            perplexity.append(self.loss_fn(shift_logits[i], shift_labels[i]))  # 計算交叉熵損失
        perplexity = torch.stack(perplexity, dim=0)  # 將損失列表轉換為張量

        if self.reduce:
            perplexity = torch.mean(perplexity)  # 如果需要,對所有損失取平均
        return perplexity

具體步驟:

  • logits 是模型的輸出,它預測了每個 token 的可能性分布。
  • labels 是原始的輸入序列,但進行了一個位移(shift),這樣我們可以將每個 token 與下一個 token 的預測進行比較,來計算交叉熵損失。
  • 每個選項的交叉熵損失通過 nn.CrossEntropyLoss() 計算,然後放入 perplexity 列表中。
  • 最後將損失值取平均,得到這個選項的最終困惑度。

也許你會好奇為什麼上面的 code 中需要 shift label 和 logits?

在大語言模型(LLM)中,模型的核心任務是 下一个詞預測,也就是根據當前的上下文預測下一個 token。這是模型學習語言的基本方式。

當我們將一個問題和每個答案分別拼接後,模型會嘗試基於問題的文本來預測答案。這就是為什麼我們要用「位移」來計算損失,因為每個 token 的預測實際上是基於它前面出現的 token。

  • 舉例說明:
    假設我們有一個簡單的問題和答案:

問題:What is the capital of France?
答案:Paris

將問題和答案拼接後的序列會變成這樣:

"Question: What is the capital of France? Answer: Paris"

當模型讀取這個序列時,它的任務是:

根據 "Question: What" 來預測下一個 token 應該是 "is";
接著,根據 "Question: What is" 預測下一個 token 是 "the";
依此類推,直到預測出 "Paris"。

在計算損失時,我們需要將模型的 logits(模型的輸出預測)和 labels(實際的正確答案)進行比較,而這裡的 位移 是為了讓我們能夠比較「模型的預測」和「下一個 token 是什麼」。

具體地說,logits 是模型對當前 token 的預測,而 labels 是下一個 token 的實際值。我們將 labels 向右位移一個位置,這樣可以確保每一個 token 的預測與它應該對應的下一個 token 進行比較。

所以回到剛剛的例子。
假設我們的輸入序列是:

Input: "What is the capital of France? Paris"

logits 是模型對每個 token 的預測,而 labels 是實際的 token:

logits: ["What", "is", "the", "capital", "of", "France?", "Paris"]
labels: ["is", "the", "capital", "of", "France?", "Paris", "<END>"]

這裡,我們希望模型能夠預測出每個 token 的下一個 token,所以我們要將 labels 進行位移,來與模型的預測進行對比。

logits["What"] 應該被用來預測 labels["is"];
logits["is"] 應該被用來預測 labels["the"];
依此類推,直到最後一個 token。

我們繼續看@PSI寫的 code:

for j in range(len(cols)):  # cols = ["A", "B", "C", "D", "E"]
    p = perp(output[j].unsqueeze(0), labels[j].unsqueeze(0))  # 計算第 j 個選項的困惑度
    perps.append(p.detach().cpu())  # 將困惑度加入 perps 列表

這段代碼對每個選項的 logits 和對應的 labels 進行損失計算,並將結果存入 perps 列表中。detach() 是為了從計算圖中分離出來,防止進一步反向傳播;cpu() 是將張量移到 CPU,方便後續處理。

每個選項的困惑度計算完成後,將其儲存在 perps 列表中。

perps = np.array(perps)
predictions = [np.array(cols)[np.argsort(perps)]]  # 根據困惑度排序,選擇最低的作為預測

最後,根據困惑度排序,困惑度最低的選項被認為是最有可能的正確答案。np.argsort(perps) 會返回困惑度從低到高的排序,然後選擇第一個選項作為最有可能的答案。

用上面的方式分別在那 200 筆訓練資料測試,以及到 leaderboard 上說測 testing data 後的 MAP@3 分數如下:

Dataset MAP@3
Training Data 0.675
Leaderboard 0.591

差了 8.4% 左右,確實 testing data 會比 training data 更 tricky,但好像也沒有到差很多 which is good news!

🔭 關於 Baseline 的探索

了解本次賽題後,大家可能會想到以下其中一種處理的範式:

  • Text-to-Label:常見的作法是用 BERT 家族中的某一個模型,輸入 text 來做多分類預測。以我們這個賽題的設定來說,就是做五分類預測。
  • Text-to-Text: 將分類問題轉為文本生成問題,直接讓模型生成答案

我們試著分別用這兩種方法來建構我們的 baseline 看看吧!

Text-to-Label

從本系列文前期分享的自動作文評分競賽([Day 5] Kaggle 自動作文評分競賽(四)- 前四名優勝作法解析:彼此制衡的兩階段式微調、發揮奇效的 pseudo-labeling、集成多種 BERT Pooling 與最優化策略)還有自動摘要評分競賽([Day 9] Data "Diversity" is King! 運用 LLM 和 Meta Pseudo Labeling 擴增數據集, 佐以 DeBERTa 和 LBGM 的多模態策略),可以發現這種文本分類比賽,大家很愛用 deberta-v3 的模型。我們這次也繼續沿用。

Simple Baseline

拍腦袋會想到的解法,應該是我們就把主辦單位提供的 200 條訓練資料拿來切 cross-validation 訓練模型。模型架構就是 DeBERTa 後面接一層 NN ,輸出設定 5 個 neuron;資料集中的問題 prompt 當成 input,output 就是 answer。

但是只有 200 筆資料根本太少很難訓練,這時候我們可以利用 chatgpt 幫我們多生成一些資料,例如多生成 500 筆給我們訓練。完整代碼請參考2

要怎麼生成好用的 data 是一件技術活,我們明天會仔細來討論這一 part。

我們時常在做研究或工作時,會遇到某個 domain 的資料太少難以訓練模型的窘境,這時候我們會希望能有好的資料生成策略來提升領域內的訓練效果。我們明天就會一起來看看大家是怎麼創造高品質的科學主題資料集,也會討論到一些生成資料集時需要注意和考慮的事情。

跑完上面的 baseline,會發現 LB 分數大概是在 0.7135 左右,金牌搖滾區都到 0.92 以上,代表這個方法還有很大的進步空間。

這時候你會怎麼做呢?

Open Book Baseline

透過前面的資料觀察,我們發現好像大部分的考題好像都是有背有分、開書考每個人都 100 分的那種問題。

那我們何苦為難模型,要逼它記住這麼龐大的知識量呢?

我們直接外接一個資料庫,把可能跟該題相關的資料提供給模型當作上下文,讓模型做五分類預測時,除了看得到問題和選項,也看得到我們搜尋到的相關資料,讓模型可以根據這些參考資料來做出他的決定。

由於主辦單位說這些問題是根據 Wiki 上面的科學相關文章生成的,所以我們也使用 Wiki dataset - wikipedia-20230701 來當作這個外援資料庫。

但是 Wiki 的詞條已經很多了,詞條下相關的文章更是多,我們是不可能把這些文章一股腦地全部塞給 deberta 叫他看完之後回答問題的。

我們只能先幫模型篩選和該題相關的參考資料。

問題來了,要怎麼大海撈針,找到和當前的問題 prompt 相關的資料呢?

一個廣泛使用的方法是,先把資料庫中的相關 title, text 用某個 embedding model 轉成向量,同時也把當前的 prompt 用同樣的模型轉成向量;接下來透過 cosine similarity 的方式計算和當前 prompt embedding 最相似的文本向量。找出 topk 個最相關的文本後,接在一起當成參考資料,和 promptChoice 都接在一起,變成 [context]參考資料[Prompt]問題[Choice]選項A, 選項B, 選項C, 選項D, 選項E 類似這樣的形式輸入給 deberta 分類模型做預測。

實現的步驟如下,完整代碼請參考3:
1. 獲取最新的 Wikipedia 資料庫:

從 Wikipedia 下載最新的數據轉儲,這裡使用的是 Kaggle 上的數據集,這些資料包含了 Wikipedia 文章的純文本版本。

2. 將問題轉換為嵌入向量:

使用 sentence transformers(句子變壓器模型)將每個問題轉換成嵌入向量,具體使用的是 all-MiniLM-L6-v2 模型。這些嵌入向量能夠表示問題的語義,方便後續的相似度計算。

3. 將 Wikipedia 文章轉換為嵌入向量:

同樣,將所有 Wikipedia 文章也轉換為嵌入向量。為了增強上下文的效果,每篇文章的第一句被用作更多的上下文信息,這裡也使用 all-MiniLM-L6-v2 模型來生成嵌入向量。

4. 使用 FAISS 進行相似度檢索:

使用 FAISS 庫來進行相似度搜索,找到與問題嵌入向量最相似的 Wikipedia 文章的前 k 篇。這樣可以快速定位最有可能包含問題答案的文章。

5. 獲取文章的全文並分割成句子:

找到最相關的文章後,獲取這些文章的完整文本,並使用 Blingfire 這個快速分詞工具將文章分割成句子。這樣能進一步提高檢索的精確度。

6. 將句子和問題選項轉換為嵌入向量:

將分割後的句子也轉換為嵌入向量,並同時將問題和答案選項轉換為嵌入向量,為了進行更精確的相似度匹配。

7. 相似度檢索最匹配的句子:

再次使用 FAISS 進行相似度檢索,這次是在問題和答案選項的嵌入向量與分割後的句子嵌入向量之間進行匹配,找到與問題最相關的前 k 個句子。
將問題、答案選項和上下文結合:

最後,將問題、答案選項和從 Wikipedia 文章中檢索到的上下文句子結合,餵入模型處理。

這個做法大概可以在 LB 上得到:0.7606

以下是上述兩個 baseline (都只使用單個模型),在 LB 上的分數:

Method LB
Simple Baseline 0.7135
Open Book Baseline 0.7606

看得出來,開書考還是表現得比較好🥂!

Text-to-Text

deberta 的參數量大概是 300M,我們現在有一大堆開源預訓練好的 LLM 動不動就 7B, 13B 的參數量,為何要棄這些大鯨魚不用改用小蝦米呢?
我們不如直接把問題問這些 LLM 讓它回答,說不定不用 fine-tuned 直接 zero-shot,效果就會比我們辛辛苦苦訓練 deberta 的效果還要更好呢!

但是現在開源 LLM 那麼多,我們要怎麼挑選合適的 LLM 來嘗試呢?

HuggingFace 有 maintain 一個 Open LLM Leaderboard (每次打開都會比較久,需要耐心等待)。

上面的儀表板可以選擇要看哪些 benchmark,下面則會顯示每個開源模型在我們選擇的那些 benchmark 取得的分數:
image
image

我們可以從中挑選符合自己需求的 top LLM 模型。

既然要用 LLM 來回答,那我們就會面臨一個問題:給模型的指令(prompt)要怎麼設定呢?

Simple Baseline

網友@SIMJEG發現一件有趣的事情,他發現下面兩種問法得到的答案會不太一樣:
問法1:下面這個回覆是不是給定問題的答案呢?回答是或否

Instruction: is the answer correct ?
Question: <question>
Answer: <answer A, B, C, D or E>
Response (yes or no): 

問法2:下列五個選項中,何者最能回答給定的問題?

Instruction: what is the best answer ?
Question: <question>
Answer A: <answer A>
Answer B: <answer B>
…
Response (A, B, C, D or E): 

我們通常會想到用「問法2」來問模型,因為它比較符合我們從小到大作題的習慣。

但是@SIMJEG發現,同樣的問題重複問 5 遍,模型每次的回答可能都不太一樣。所以這兩個問法他都會重複問五遍,最後用多數決來決定最終答案,結果發現問法1的效果比問法2好~

用不同問法問 Platypus2-70B 模型,在 LB 上得到的分數如下:

問法 LB
問法1 0.81
問法2 0.75

差距滿明顯的!

所以接下來他都採用問法1的形式來做相關 LLM 的實驗。

Open Book Baseline

前面用 deberta 有嘗試檢索(retrieve)wiki dataset的相關文本當作上下文,給模型做預測,這邊 LLM 也可以做類似的事情,把檢索到的資料放到下面 prompt 的 區域如下:

Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.

### 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 pertinent.

### Input:
Context: <context>
Question: <question>
Proposed answer: <answer>


### Response:
Model w/o RAG w/ RAG
Deberta-V3 0.71 0.76
Platypus2-13B 0.82 0.85
Platypus2-70B 0.83 0.89

從上面的實驗結果我們可以觀察到兩件事情:

  1. 有做 RAG 可以提升 LB 的分數
  2. LLM 參數多的模型表現得比參數小的模型好

第一點沒什麼問題,我們從上述幾個實驗和觀察確定這次比賽要用「開書考」的形式完成才會高分,也就是需要建立一個檢索系統

但是第二點就有大問題了。Kaggle 的硬體限制下,在不做量化(quantization)的情形,我們跑個 7B, 13B 的模型就很慢了,要怎麼塞得下 70B 的模型呢?

這時候就要介紹一個新的技術了----

LLM 分層加載技術(layer-wise loading)- 用時間換空間

Platypus2-70B 模型是一個非常大的模型,包含 70 億個參數。通常這樣的模型在推理時,無法一次性加載到 GPU(尤其是像 T4 這樣的 GPU)。因此,為了讓這樣的模型能夠運行,我們可以使用了一種稱為「分層加載」的技術,具體來說是逐層加載模型,並在每層的計算完成後釋放 GPU 記憶體,然後加載下一層。

我們一起來看看這具體要怎麼做呢?

首先,先儲存模型權重到 safetensors。
由於 Platypus2-70B 的權重非常大,必須使用 safetensors 格式來存儲每一層的權重,這樣可以讓每次運行時只需加載當前層的權重。
為了在 Kaggle 上運行,還需要將模型分成兩個檔案,因為 Kaggle 的單個檔案大小限制是 107GB,而模型的 FP16 權重總大小為 129GB。

for part in [1, 2, 3]:
    source_dir = Path(f'/kaggle/input/platypus2-chuhac2-part{part}')
    for path in source_dir.glob("*"):
        (checkpoint_path / path.name).symlink_to(path)

  1. 準備權重分層加載的工具類
    首先,我們先創建了一個 WeightsLoader 類來進行分層加載。這個類負責在推理過程中動態地從硬碟中加載每一層的權重,並將其加載到 GPU 上運行,這樣可以避免 GPU 記憶體超載。
class WeightsLoader:
    """
    用於線程安全地加載模型權重的類。
    權重會在後台加載,當需要時可以通過 get_state_dict() 來訪問。
    所有設備在加載權重之前都需要調用 set_state_dict()。
    """
    def __init__(self, checkpoint_path, devices):
        self.checkpoint_path = Path(checkpoint_path)
        self.states = {device: None for device in devices}
        self.state_dict = None
        self.condition = Condition()

    def get_state_dict(self, device):
        with self.condition:
            while self.states[device] is not None:
                self.condition.wait()
            result = self.state_dict
            self.states[device] = None
            if not any(self.states.values()):
                self.condition.notify_all()
        return result

    def set_state_dict(self, layer_name, device):
        with self.condition:
            self.states[device] = layer_name
            if all(self.states.values()):
                assert len(set(self.states.values())) == 1, "所有設備應加載相同的層"
                self.state_dict = load_file(self.checkpoint_path / (layer_name + ".safetensors"), device="cpu")
                for d in self.states:
                    self.states[d] = None
                self.condition.notify_all()

set_state_dict:將要加載的層的名稱設置到指定設備(GPU),當所有設備都設置好後,就會開始實際加載該層的權重。
get_state_dict:當層的權重被加載到 CPU 上後,返回該權重以供模型使用。
這樣的設計確保了多個 GPU 可以協同運行,每個 GPU 會等待其他 GPU 同步權重的加載。

  1. 創建分層加載的模型類
    接下來,我們定義了一個名為 ShardedLlama 的類,這個類負責逐層加載模型的權重並進行推理。模型的每一層會動態加載到 GPU 上,計算完成後將其釋放,然後加載下一層。
class ShardedLlama:
    def __init__(self, checkpoint_path, weights_loader, device="cuda:0", dtype=torch.float16):
        self.checkpoint_path = Path(checkpoint_path)
        self.weights_loader = weights_loader
        self.device = device 
        self.dtype = dtype
        self.init_model()
        self.layer_names = ["model.embed_tokens"] + [f"model.layers.{i}" for i in range(len(self.model.model.layers))] + ["model.norm", "value_head"]

    def init_model(self):
        with init_empty_weights():
            self.model = AutoModelForCausalLM.from_config(self.config)
            self.model.eval()
            self.model = BetterTransformer.transform(self.model)  # 啟用 Flash Attention
            self.model.tie_weights()
        self.layers = [self.model.model.embed_tokens] + list(self.model.model.layers) + [self.model.model.norm, self.model.lm_head]

    def load_layer_to_cpu(self, layer_name):
        self.weights_loader.set_state_dict(layer_name, self.device)
        state_dict = self.weights_loader.get_state_dict(self.device)
        return state_dict
        
    def move_layer_to_device(self, state_dict):
        for param_name, param in state_dict.items():
            set_module_tensor_to_device(self.model, param_name, self.device, value=param, dtype=self.dtype)

    def __call__(self, inputs):
        # inputs = [(prefix, suffix), ...] with prefix.shape[0] = 1 and suffix.shape[0] = 5
        
        # 重啟模型以確保緩存已清空
        del self.model
        clean_memory()
        self.init_model()
        
        # 設置輸入批次到設備
        batch = [(prefix.to(self.device), suffix.to(self.device)) for prefix, suffix in inputs]
        n_suffixes = len(batch[0][1])
        suffix_eos = [(suffix != self.tokenizer.pad_token_id).sum(1) - 1 for _, suffix in inputs]

        # 創建注意力掩碼和位置 ID 用於 KV-cache
        attention_mask = torch.ones(MAX_LENGTH, MAX_LENGTH).triu(diagonal=1)[None, None, ...] == 0
        attention_mask = attention_mask.to(self.device)
        position_ids = torch.arange(MAX_LENGTH, dtype=torch.long, device=self.device)[None, :]

        with ThreadPoolExecutor() as executor, torch.inference_mode():
            # 加載第一層
            future = executor.submit(self.load_layer_to_cpu, "model.embed_tokens")
            for i, (layer_name, layer) in tqdm(enumerate(zip(self.layer_names, self.layers)), total=len(self.layers)):
                state_dict = future.result()
                if (i + 1) < len(self.layer_names):
                    future = executor.submit(self.load_layer_to_cpu, self.layer_names[i + 1])
                self.move_layer_to_device(state_dict)

                # 運行層計算
                for j, (prefix, suffix) in enumerate(batch):
                    if layer_name == "model.embed_tokens":
                        batch[j] = (layer(prefix), layer(suffix))
                    elif layer_name == "model.norm":
                        batch[j] = (None, layer(suffix[suffix_eos[j]][:, None]))
                    elif layer_name == "value_head":
                        batch[j] = layer(suffix)[:, 0].mean(1).detach().cpu().numpy()
                    else:
                        len_p, len_s = prefix.shape[1], suffix.shape[1]
                        new_prefix, (k_cache, v_cache) = layer(prefix, use_cache=True, attention_mask=attention_mask[:, :, -len_p:, -len_p:])
                        pos = position_ids[:, len_p:len_p + len_s].expand(n_suffixes, -1)
                        attn = attention_mask[:, :, -len_s:, -len_p - len_s:].expand(n_suffixes, -1, -1, -1)
                        kv_cache = (k_cache.expand(n_suffixes, -1, -1, -1), v_cache.expand(n_suffixes, -1, -1, -1))
                        new_suffix = layer(suffix, past_key_value=kv_cache, position_ids=pos, attention_mask=attn)[0]
                        batch[j] = (new_prefix, new_suffix)

                # 清空 GPU 的當前層,並加載下一層
                layer.to("meta")
                clean_memory()
        return batch
  • 初始化模型 (init_model):

使用 init_empty_weights() 創建模型,但不分配任何實際權重,這樣不會佔用 GPU 記憶體。
當需要計算時,實際權重會在推理過程中動態加載。

  • 逐層加載權重 (load_layer_to_cpu):

使用 WeightsLoader 來加載模型的每一層權重,每次只加載當前需要的層,並且在該層計算完成後將其從 GPU 釋放。

  • 執行層的計算 (call):

在每一層加載到 GPU 之後,對當前輸入進行前向傳播計算。
計算完每一層的輸出後,將該層的權重從 GPU 釋放,然後加載下一層。

  1. 進行推理
def run_model(device, df, weights_loader):
    model = ShardedLlama(checkpoint_path, weights_loader, device=device)
    inputs = df.apply(f, axis=1).values
    batches = np.array_split(inputs, N_BATCHES)
    outputs = []
    for i, batch in enumerate(batches):
        outputs += model(batch)
    return outputs

完整code請參考:45

接下來,我們會討論如何借助 LLM 的力量建構 high quality 的訓練資料集,來幫助我們 fine-tune 不管是 deberta 或是 LLM 模型。

我們明天見!


謝謝讀到最後的你,希望你會覺得有趣!
如果喜歡這系列,別忘了按下訂閱,才不會錯過最新更新,也可以按讚給我鼓勵唷!
如果有任何回饋和建議,歡迎在留言區和我說✨✨


Kaggle - LLM Science Exam 解法分享系列)


上一篇
[Day 15]🧟成為特級LLM咒言師的第四天 - 為什麼"lucrarea"咒語會這麼強大?一些實驗設計與思考 - 淺談文本對抗攻擊(Adversarial Attack)實作篇
下一篇
[Day 17]🧐如何利用LLM生成High Quality的增強版訓練數據集?
系列文
一個Kaggle金牌解法是如何誕生的?跟隨Kaggle NLP競賽高手的討論,探索解題脈絡30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言