如何產生一個優質的 dataset 來增強訓練?
生成優質的資料集需要考慮哪些事情呢?
昨天我們介紹這個比賽的時候,有提到主辦方只給我們少量的訓練資料(200筆),但考題範圍很廣,大部分來自普物然後一些生物的題目。所以如何建立一個外掛資料庫幫助模型「開書考」,以及生成和主辦方提供的 train/test 比較相似的資料,以用來訓練回答問題的模型,應該是我們開發解題方案的重點!!
昨天已經有簡單介紹過「外掛資料庫」大致的思路了,下面來幫大家複習一下:
<context>
。(這部分可以用 faiss
來加速,相關使用介紹可參考:全端 LLM 應用開發-Day13-用 FAISS 來儲存向量資料)今天的重點會在「如何建立用來微調模型的訓練資料集」呢?
在創建額外的訓練用的假資料時,最重要的事情就是在增加「豐富度」的同時,不能讓我們生成的假資料和真實資料差異太大!
但是什麼叫做差異太大?這可以從很多角度去思考,往往隨著我們對資料瞭解的更深入,我們會更清楚要在哪些 dimension 維持一致性。
創造假資料要考慮的兩個要點:
- 保持和 real train/test set 一樣的分佈
- 增加假資料的豐富度,創造比 training data 更多的信息量
我們就第一點開始討論~
最容易想到的控制差異的面向,就是讓假資料的字數分佈不要和真實資料差異太大。
train.csv
, test.csv
包含 prompt
, A
, B
, C
, D
, E
與 answer
等欄位。
我們來看一下文字欄位的字數分佈:
Prompt 平均都在 10 幾個字左右,每個選項大約在 20 幾個字,最多不會超過 150 個字。
在競賽的說明頁中,有提到主辦方是從 Wiki 上面抓取 science-based 的文章,再讓 chatgpt 根據這些素材重新出考題。
為了讓創造出來的假資料和 train/test 不要差太多,我們現在也可以還原這個流程,用同樣的流程創造更多訓練資料。
建立一個STEM(Science, Technology, Engineering, and Mathematics)類別的列表,以便在相應的頁面中搜索並從中提取測試內容。
選擇類別或與相應主題或子主題相關的頁面。這邊有兩種做法:
2(a). 隨機選取
2(b). 選取和 training data 最相關的類別,再從這些類別中收集wiki頁面(page)選擇頁面後,從中提取文本。
向大語言模型設計一個 prompt/instruction,說明需要完成的任務,並提供剛剛提取的文本。
解LLM的輸出,並自動檢查其是否符合輸出格式。
Wikipedia 的每篇文章底部通常會列出它所屬的分類。這些分類用來將相似主題的頁面組織在一起,方便讀者進行主題間的導航。
當你點擊分類名稱時,會進入一個分類頁面,列出屬於這個分類的所有文章,以及該分類的子分類。這樣可以幫助你構建一個類似樹狀結構的分類系統,方便在大主題下導航不同的子主題。
舉例來說:
我們搜尋 category list 裡面的 Category:Subfields by academic discipline
接下來我們點選:Category:Fields of mathematics 看看會是什麼
會看到這個類別下面的子類別,或是屬於這個類別的文章。接下來就可以選擇要繼續往子類別深挖,還是直接爬走這些page即可。
雖然我們只關注 STEM 的內容,但 WIKI 上面和 STEM 相關的 category 和 page 還是超級無敵爆炸多的💥!
尤其我們之後要把這些資料交給 chatgpt 生成問題,如果全部送過去,還不一定能得獎拿獎金,就先把自己口袋的錢都交給 openai 了QQ。
所以我們可以有兩種做法:
(a). 隨機選取。既然沒辦法全部都要,那就隨機選,一切看緣分吧!
(b). 找出和 training data 最相關的類別,再從這些類別收集 page。這種方法希望能從這些海量的類別與頁面中,盡可能猜出有哪些比較像主辦方提供的 training data 會使用到的類別,觀察看看是否有某種規則或模式,讓我們能從大量的候選類別中,找出某些和比賽資料最相關的類別,接下來我們只需要去收集這些類別旗下的 Pages 即可。
但是這種做法產生出的資料雖然會和已知的訓練資料主題上比較像,卻可能會因為缺乏豐富度,導致訓練出來的模型在 testing data 上遇到沒看過的類別,就會不知道怎麼判斷。
(a). 隨機選取的作法介紹
stem_label, stem_categories = random.choices(list(STEM.items()), weights=STEM_WEIGHTS, k=1)[0]
從已定義的 STEM 主題("S"、"T"、"E"、"M")中隨機選擇一個,並根據之前定義的 STEM_WEIGHTS 來調整選擇的機率(例如,科學類的權重較高)。
category = random.choice(stem_categories)
category_page = wiki_wiki.page(category)
chosen_list = list(category_page.categorymembers.items())
if deep_subcategories:
category_list, page_list = split_category_members(chosen_list)
chosen_list = []
else:
category_list, page_list = [], []
category_list
:子分類列表。page_list
:頁面列表。def split_category_members(members):
category_list, page_list= [], []
for member_name, member_page in members:
if member_name.startswith('Category') and member_name not in EXCLUDE_CATEGORIES:
category_list.append((member_name, member_page))
else:
page_list.append((member_name, member_page))
return category_list, page_list
因為分類和分類下的 pages 都很多,全部取下來會花不少時間資料量也會很大,因此這邊採用隨機探索的方式。
# 50% 機率選擇子分類或頁面列表,如果其中之一不為空
if not (category_list or page_list) and not chosen_list:
continue
elif not category_list:
chosen_list = page_list
elif not page_list:
chosen_list = category_list
else:
chosen_list = random.choice([category_list, page_list])
如果有子分類或頁面可選,代碼會隨機從子分類列表和頁面列表中選擇其中一個進行操作。
# 從選定的列表中選擇隨機頁面
selected_page_name, selected_page = random.choice(chosen_list)
if not selected_page_name.startswith("Category"):
break
category_page = selected_page
檢查選擇的結果:如果選擇的是一個頁面(而不是分類),搜索結束,並返回該頁面。如果選擇的是子分類,代碼會將這個子分類作為新的 category_page,並繼續重複步驟 3 到 5。
最終返回:當選擇的是一個頁面(即不以 "Category" 開頭),這個過程結束,並返回該頁面。
(b). 挑選和 training data 相關的類別,再從這些類別收集 pages
train_pages = [
'Supersymmetric quantum mechanics',
'Relative density',
'Memristor',
...
]
一共找到 154 個不同的 wiki page。
2. 定義找 Page 所屬的類別(category)和子類別(subcategory)的 parsing function。這邊我們可以設定找子類別的深度最深要到多少,我們預設 3。
def get_categories(page_title):
url = wiki_wiki.page(page_title).fullurl
response = requests.get(url)
soup = BeautifulSoup(response.text, 'html.parser')
categories = [x.text for x in soup.find(id="catlinks").find(id="mw-normal-catlinks").find_all("li")]
return categories
def get_categories(page_title):
url = wiki_wiki.page(page_title).fullurl
response = requests.get(url)
soup = BeautifulSoup(response.text, 'html.parser')
categories = [x.text for x in soup.find(id="catlinks").find(id="mw-normal-catlinks").find_all("li")]
return categories
for page in tqdm(train_pages):
categories = get_categories_deep(page, depth=2)
categories_graph = nx.from_dict_of_dicts(categories, create_using=nx.DiGraph)
page_graphs.append(categories_graph)
overall_graph = nx.DiGraph()
for page_graph in page_graphs:
overall_graph = nx.compose(overall_graph, page_graph)
這裡使用 NetworkX 庫將每個訓練頁面的分類結構轉換為有向圖(nx.DiGraph),並將它們組合成一個總的圖(overall_graph)。
我們來舉簡化過的例子說明看看:
我們先幫 "Memristor" 這個 page 畫它的分類圖:
再幫 "Quantum electronics" 這個畫它的分類圖:
兩者畫出來的圖長下面這樣:
如果我們用 'Supersymmetric quantum mechanics' 這個 Page 所屬的類別,以及類別和類別之間的子類別等的關係,真實畫出來的圖會長這樣:
接下來我們可以合併所有 page 的子圖,畫成一張巨大的圖。
我們想要了解到底哪些「藍色」category的節點,和最多「紅色」 page 的節點相連呢?
leaf_connections = defaultdict(list)
for node in overall_graph:
end_nodes = [x for x in nx.nodes(nx.dfs_tree(overall_graph, node)) if overall_graph.out_degree(x)==0]
leaf_connections[len(end_nodes)].append(node)
# 找到與最多頁面相連的分類
unique_num_connections = sorted(leaf_connections.keys())
max_num_connections = max(unique_num_connections)
pprint(leaf_connections[max_num_connections])
這段代碼計算每個分類節(藍色 node)點與多少個訓練數據中的頁面(紅色 node)相關,並按連接的頁面數量進行分類。
舉剛剛的"Memristor"和"Quantum electronics"為例,和最多 page 相連的分類節點,就是 "Electronics" 了。
如果我們幫所有 train pages 建立 graph 並合併起來看到底哪些 category node 與最多 page 相連結,可以發現:
['Category:Main topic classifications',
'Category:Natural sciences',
'Category:Articles']
這三個 category 和 154 個 train pages 有 150 次的 connections,是連接數量最多的 category node。其中兩個看起來是維基百科中使用的通用類別,但Natural sciences顯然與我們在本次競賽中使用的數據相關。這可能表明,專注於Natural sciences類別的文章可能是過濾我們數據集的一個好方法!
但是同樣和page node 相連的 category node,也是有親疏遠近之分的。
比方我們剛剛舉的子,Electronics 和 page node "Memristor" 與 "Quantum electronics" 都距離為 1,算是最靠近 page node 的 category node 了。這種 node 和 page 的關係最為緊密,就像親緣關係中的直系血親。但如果有些 node 雖然和很多 page 相連,卻都距離 page 很遙遠,那就像遠親一樣,這些 node 就應該給予比較低的重要性。
所以我們不光考慮 node 和多少 page 相連,還要考慮他們之間的距離,距離越近、連接數量越多的 node,才是我們要尋找的 category node。
spl = dict(nx.all_pairs_shortest_path_length(overall_graph))
leaf_connections_distance = defaultdict(float)
for node in overall_graph:
end_nodes = [x for x in nx.nodes(nx.dfs_tree(overall_graph, node)) if overall_graph.out_degree(x)==0]
for end_node in end_nodes:
if end_node != node:
leaf_connections_distance[node] += 1/spl[node][end_node]
top_leaf_connect_distances = sorted(leaf_connections_distance.items(), key=lambda x:x[1], reverse=True)
pprint(top_leaf_connect_distances[:10])
這裡基於圖中每個分類節點與其最終連接的頁面之間的距離,計算分類的重要性。計算公式為所有距離的倒數之和,這意味著距離較近的頁面將會有更大的權重。這可以幫助我們確定那些與訓練數據最密切相關的分類,這些分類將有助於在 Wikipedia 中選擇最相關的頁面。
我們在 train pages 的overall graph上去計算每個 node 的 connection/distance score,然後把 top 50 的 category print 出來:
Top 50 connection/distance scores
[('Category:Concepts in physics', 59.0984126984127),
('Category:Concepts by field', 42.90479797979802),
('Category:Physics', 42.26829004329007),
('Category:Subfields of physics', 37.716269841269835),
('Category:Physical sciences', 37.33095238095237),
('Category:Subfields by academic discipline', 36.35238095238095),
('Category:Physical phenomena', 36.318253968253956),
('Category:Main topic classifications', 35.085714285714275),
('Category:Concepts', 34.82943722943723),
('Category:Physical quantities', 34.773409923409915)]
看起來“Concepts in physics”類別確實值得用來收集更多的訓練數據,我們應該集中火力在收集這個類別下的文章,並用這個類別的文章生成更多各式各樣的問題!
下面這個 function 從指定的 Wikipedia 分類開始,遞歸地查找所有相關的子分類和頁面,並將這些頁面匯總。這使得我們能夠從一個初始分類出發,查找該分類及其子分類中的所有相關頁面,這樣可以用來收集訓練數據。
def get_all_pages_category_deep(init_category: str, depth: int = 2):
category_page = wiki_wiki.page(init_category)
chosen_list = list(category_page.categorymembers.items())
category_list, page_list = split_category_members(chosen_list)
category_pages = {init_category: page_list}
for i in range(depth):
new_category_list = []
for category, _ in category_list:
category_page = wiki_wiki.page(category)
sub_category_list, page_list = split_category_members(list(category_page.categorymembers.items()))
category_pages[category] = page_list
new_category_list.extend(sub_category_list)
category_list = new_category_list
all_pages = [j for x in category_pages.values() for j in x]
unique_pages = list(set(all_pages))
return unique_pages
我們針對剛剛找到的 "“Concepts in physics”" category,去收集這個類別下的所有文章:
concepts_in_physics_pages = get_all_pages_category_deep(
init_category = 'Category:Concepts in physics',
depth=1
)
captured = len([x for x in train_pages if x in [page_name for page_name, _ in concepts_in_physics_pages]])
missing = len([x for x in train_pages if x not in [page_name for page_name, _ in concepts_in_physics_pages]])
print(f"{len(concepts_in_physics_pages)} total pages. {captured} captured, {missing} missing from the train set")
如果我們只考慮“物理概念”這一類別,並且以深度為1進行搜索,我們能得到一個包含882個頁面的子集,其中涵蓋了訓練集中的111個頁面,占比72%!這大大減少了我們需要生成問題的頁面數量。
雖然使用“Concepts in physics”可能有助於我們解決物理相關的問題,但這肯定無法覆蓋科學的其他領域。為了進一步改進,我們可以嘗試探索圖中的其他類別,並將它們加入進來,看看是否能覆蓋更多的訓練數據集。我們還可以查看整個圖中的集群或子圖(或者甚至構建一個新的圖來表示我們缺失的頁面),以嘗試識別可以添加的其他類別。
如果該個 Page 的 sentence 長度有超過 6 句,就取前面七句存下來。(這邊的設定是這樣,但也可以視情況調整更多或更少)
def get_wiki_text(seen_pages, min_page_length=6, sentences_include=3):
while True:
wiki_page, stem_label = get_wiki_random_page()
if wiki_page.pageid in seen_pages:
continue
page_sentences = wiki_page.text.split(". ")
# check is the page is long enought
if len(page_sentences) >= min_page_length:
# main information about the topic usualy described within first 3 sentences
wiki_text = ". ".join(page_sentences[:sentences_include]) + "."
break
return wiki_text, wiki_page.pageid, wiki_page.title, stem_label
透過經驗和觀察,我們可以自己寫一個給 chatgpt 的 instruction,詳細說明我們的要求,給定的輸入和指定LLM要輸出的格式,例如下面這樣:
system_message = f"""
You will be provided with TEXT from wikipedia. \
The TEXT will be delimited with {delimiter} characters.
Output a python list of 5 dict objects, where each object is \
a multiple choice question whom answers should be in \
the given TEXT and that has 5 choices each and has the following format:
'question': <question on the TEXT>
'option_1': <question answer option>
'option_2': <question answer option>
'option_3': <question answer option>
'option_4': <question answer option>
'option_5': <question answer option>
'answer': <answer option key label>
You should tell me which one of your proposed options is right \
by assigning the corresponding option's key label in the 'answer' field.
The question, the answer and question answer options should be broad, \
challenging, long, detailed and based on the TEXT provided.
Only output the list of objects, with nothing else.
"""
我們如果有預算的話xdd,也可以多設計幾種不同的 prompt,可以設定他們要從不同角度發問或是設計不同難度的選項等,產生不同變化的資料。
然後叫 chatgpt 根據我們剛剛收集的 text 生成問題、選項與答案:
def get_completion_messages(wiki_text):
return [
{
'role':'system',
'content': system_message
},
{
'role':'user',
'content': f"{delimiter}{wiki_text}{delimiter}"
},
]
def get_completion_from_messages(
messages,
model="gpt-3.5-turbo",
temperature=0.8,
max_tokens=3000
):
response = openai.ChatCompletion.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
)
return response.choices[0].message["content"]
接下來我們要把上面寫的東西串起來,具體的流程就是從每個 Wikipedia 頁面提取內容,然後通過 LLM(如 GPT 模型)生成 5 個多選題,並將這些題目按照特定格式進行存儲和檢查。如果生成多選題的過程出現錯誤或格式不正確,代碼會重新嘗試,直到達到最大嘗試次數或成功生成問題。
def is_correctly_formatted(mcq) -> bool:
return all([
len(el) == len(response_keys_set) and response_keys_set == set(list(el.keys()))
for el in mcq
])
is_correctly_formatted(mcq)
:這個函數的作用是檢查 LLM 生成的多選題(mcq)是否正確格式化。
它檢查每個問題的字典是否包含所有所需的鍵,如 "question"、"option_1"、"answer" 等。
def gather_multiple_choice_question_dataset(
pages_count: int,
max_completion_attempts: int = 10,
seen_pages: list = []
):
gather_multiple_choice_question_dataset
函數:這是主函數,負責生成指定數量的多選題數據集。pages_count
:表示希望生成多選題的 Wikipedia 頁面數量。每個頁面應生成 5 個多選題。max_completion_attempts
:表示每個頁面最多允許重試多少次。如果某頁面在多次嘗試後仍無法正確生成問題,則放棄該頁面,選擇下一個頁面。seen_pages
:用來跟蹤已經處理過的頁面,防止重複處理相同的頁面。
wiki_text, page_id, page_title, stem_label = get_wiki_text(seen_pages, sentences_include=7)
print(f"\nStart multiple choice questions generation: page_id={page_id}, page_title={page_title}, stem_label={stem_label}")
messages = get_completion_messages(wiki_text)
接下來我們循環嘗試生成多選題:
attempts_counter = 0
while True:
try:
chatgpt_response = get_completion_from_messages(messages)
mcq = eval(chatgpt_response)
if not isinstance(mcq, list) or len(mcq) < 5 or not is_correctly_formatted(mcq):
raise Exception
將消息發送給 LLM,並使用 get_completion_from_messages
() 獲取模型的回應(chatgpt_response)。通過 eval() 函數將生成的字符串轉換成 Python 對象(即多選題列表 mcq)。
然後通過 is_correctly_formatted(mcq)
函數來檢查生成的多選題格式是否正確。如果格式不符合要求,則拋出異常並重新嘗試。
for i in range(len(mcq)):
mcq[i]["wiki_text"] = wiki_text
mcq[i]["page_id"] = page_id
mcq[i]["page_title"] = page_title
mcq[i]["stem_label"] = stem_label
if mcq[i]["answer"] in options_set:
continue
else:
answ_indx = [v.lower() for v in mcq[i].values()].index(mcq[i]["answer"].lower())
mcq[i]["answer"] = list(mcq[i].keys())[answ_indx]
except Exception:
attempts_counter += 1
print("Attempts count:", attempts_counter)
attempts_list.append(attempts_counter)
if attempts_counter > max_completion_attempts:
break
如果生成多選題失敗或格式不正確,程序會捕獲異常,增加重試次數(attempts_counter),並記錄每次重試的次數。如果重試次數超過 max_completion_attempts,則停止該頁面的生成過程,並選擇下一個 Wikipedia 頁面。
(以上解讀改寫自 Notebook1 與 Notebook2。)
生成額外的訓練資料時,我們當然希望可以讓些「假資料」在保持和真實訓練資料一定相似度的前提下,盡可能地增加這些擴增資料的資訊量與豐富度,以利在這些資料上訓練的模型,可以更好地 transfer 到原始訓練資料以外的 domain。
所以我們也嘗試納入一些不是和主辦方一樣用 wiki + chatgpt 產生出來的科學相關問題,找一些公開的資料集,可能是人工撰寫、學校考題之類的來源,再剔除掉一些明顯和我們真實訓練資料差太多的 sample,一起組建一個更豐富的擴增資料集。
原始的MMLU數據集包含100,000個問題,但其中許多問題非常長,並且風格與我們的競賽問題不同,我們可以移除這些問題,只保留了17,000個在風格和長度上(prompt不超過32個單詞)與我們的競賽問題相似的問題。但是要注意的是,MMLU 的問題都只有 4 個選項,但我們的問題都有 5 個選項。
加入 ScienceQA Dataset: 是一個專門用來評估模型在科學問題解答能力的資料集。它包含來自小學到中學級別的 21,208 道多選科學題目,涵蓋物理、化學、生物等學科。每個問題都包含相關的文本、圖像或圖表,並提供詳細的解釋和答案。
包含10,000個需要圖片的問題和10,000個不需要圖片的問題。這一點可以通過名為image的列來指示。考慮移除df.image.notnull()的行。此外,ScienceQA數據集還有描述主題和年級級別的附加列。我們可以使用這些列來過濾問題,或者忽略這些附加列。
ScienceQA數據集分別有5個、4個、3個和2個選項的問題數量分別為1,920、4,893、5,078和11,045個。(名為ct的列表示答案選項的數量)
加入 OpenBookQA dataset: 是一個用來測試語言模型在常識推理和科學知識推理方面能力的資料集。它包含 5,957 道多選題,這些題目基於小學科學教科書中的“開放式書本”知識,需要結合課本內容和常識推理來回答。每道題有 4 個選項,題目設計鼓勵模型不僅依賴於題幹中的信息,還需要進行外部知識推理才能得出正確答案。
這個 dataset 包含3,300個以“完成句子”形式編寫的問題和1,700個以“問題”形式編寫的問題。我們可以只保留已問題形式編寫的問題,或者為了多樣性,我們可以用全部5,000個問題進行訓練,只要讓ChatGPT將陳述提示轉換為問題提示就可以了。
可以發現這些現成的 dataset,有些有 5 個選項,有些只有 4 個或更少。其實我們不一定只能保留有 5 個選項的那些 data,關鍵還是在我們怎麼設計我們的回答模型。
如果我們要用 Multi-Class 的方式訓練,也就是一次給模型看問題和所有選項,最後叫模型做五分類,或是預測唯一的答案,那這樣就只能保留有 5 個選項的問題,或是我們重複把錯誤答案貼上空缺的部分,假裝有第五個選項。
(圖片來源:1)
或者我們可以用 Multi-Label 的方式來訓練模型,也就是一次只給模型問題和一個選項,叫模型回答正確答案是不是這個選項,變成二分類問題,那這樣就不用在乎到底有提供幾個選項了。
(圖片來源:1)
今天帶大家從兩個前提:「維持和真實訓練資料的分佈相似性」與「增加訓練資料所沒有的信息量與豐富度」兩個角度,帶大家實際跟著這個 Science Exam 的比賽的需求擴增訓練數據。
至於到底要擴增多少 data 才夠呢?
這一方面取決於我們擁有的時間、金錢等資源限制,一方面也需要我們分批次產生資料後,即時拿去訓練模型,觀察同樣的模型與訓練參數的設定下,模型有沒有隨著資料量增加效果也逐漸變好。
如果有的話,恭喜你,就繼續生吧!沒有的話,有可能是這個議題靠資料堆疊產生的效益就到這邊了;也有可能是資料產生的方式有問題,已經遠離真實資料的分佈了。
我們明天會繼續討論有了資料後,要如何挑選合適的 LLM 來訓練!
大家明天見~
謝謝讀到最後的你,希望你會覺得有趣!
如果喜歡這系列,別忘了按下訂閱,才不會錯過最新更新,也可以按讚給我鼓勵唷!
如果有任何回饋和建議,歡迎在留言區和我說✨✨
(Kaggle - LLM Science Exam 解法分享系列)