我們要如何利用「每次 LLM 的回覆都不一樣」這個現象,來增加產生正確答案的機率呢?今天會帶大家實際應用 self-consistency 到賽題中,並透過設計一連串小實驗探索 LLM 回答的穩定性。
既然這是一個針對數學競賽的比賽,我們在挑選開源模型作為回答模型時,應該挑一些專攻「解決數學問題」的模型,至少模型在預訓練階段要接觸大量數學相關的文本、看得懂 latex 語法;或者從昨天的 SysPy 小實驗得到啟發,我們也可以挑很會 coding 的模型來試試看!
目前專攻數學問題的模型還不是很多,比較常聽到的模型包括:MathMistral
, Qwen-Math
與 DeepSeek-math
等。
在本次賽事中,最多參賽者使用的是 DeepSeek 家的"Math模型"與"Coder模型"。
DeepSeek(深度求索)是一家成立於2023年的中國AI新創公司,其最知名的產品是 deepseek-coder 和 deepseek-math 系列的大語言模型。除了將旗下7B參數量的模型權重開源到社區外,用戶還可以通過他們的官方網站付費使用更大規模參數量版本的API服務。
今年5月他們 released 的 DeepSeek-Coder-V2
在許多評估LLM數理能力和編程能力的 benchmark 都已經超過 SOTA GPT-4 的表現。
如下圖 DeepSeek-Coder-V2
在 MBPP+, MATH, GSM8K, Aider
的Accuracy 已經超過 GPT-4 與 Gemini Pro;在 LiveCodeBench 和 SWE-Bench 也有 comparable 的表現。
(👆🏻圖片來源)
不過因為比賽限制要用今年 2/23 前公開的模型,所以本次參賽選手只能使用他們家去年提出的 v1.5 7B 版本的模型。如果把優勝方案中的 answer model 替換成最新的 V2,說不定模型能多解出幾道 testset 的題目,有時間的話我們可以來試試看!
DeepSeek Coder 由一系列代碼語言模型組成,每個模型從頭訓練,處理了 2T 的語料,其中 87% 是代碼,13% 是中英文自然語言。模型大小從 1B 到 33B 不等,在很多 programming language和benchmark中達到了open source的代碼模型的SOTA。
下圖是他們建構訓練資料的流程:
大部分的訓練資料都來自 Github 上的開源專案,在過濾掉一部分不適合的 data 後,他們會將檔案做 dependency parsing,將彼此關聯的檔案放在一起。
訓練流程分為三個步驟,前兩個步驟都是在 code-base 的相關語料上繼續做 pretraining,然後在最後一個步驟做 instruction-tuning,將只會文字接龍的模型變成問它問題它會根據題目回答的 QA model,使得模型可以根據使用者輸入的只料做出回答和交互。
目前在 HuggingFace 上可以找到他們 1.3B, 6.7B, 7B, 33B 等規模的模型~
DeepSeekMath 是基於前面介紹到的 DeepSeek-Coder-v1.5 7B 模型,並繼續在數學相關語料和自然語言及代碼數據上pre-training,訓練語料總計達到 500B tokens。DeepSeekMath 7B 在沒有使用外部工具或 majority vote 技術的前提下,在 MATH Benchmark中取得 51.7% 的 accuracy,接近 Gemini-Ultra 和 GPT-4,甚至比許多更大規模的開源模型還要表現得更好。
(難怪這次比賽金牌解法幾乎都用他們家的模型當作 backbone)
下面是他們整體的訓練資料收集流程:
簡單來說就是他們先在一個有大量高品質數學領域參考資料的網站--OpenWebMath,用網站上面的文本訓練一個 FastText model;之後用這個 model 從 Common Crawl 上篩選和OpenWebMath的文章中比較相似的數學相關網站,並透過FastText給出的分數排序,只保留topk的網站。透過不斷收集、過濾的迭代循環,最後整理出總共 35.5M 的數學 web pages,共 120B tokens。
另外他們有公開三種不同 fine-tuned 方法產生的7B模型:DeepSeekMath-Base 7B
, DeepSeekMath-Instruct 7B
, DeepSeekMath-RL 7B
。
其中 RL 版本的模型就是有再經過他們 proposed 的 "Group Relative Policy Optimization (GRPO)" 演算法再去訓練過的模型,在"Chain-of-Thought Reasoning" 和 "Tool-Integrated Reasoning" 兩個任務中,都比主流的閉源和其他開源模型表現得更好。
前面介紹那麼多 DeepSeek 家的模型,也許你會好奇 zero-shot DeepSeekMath-7B
在我們的賽題上,能解出多少題目呢?
但連直接 zero-shot 在 GPT-4, Gemini Pro 都答得零零落落,可預期 DeepSeekMath-7B
也不會好多少。
我們不如用前面提到的設計「思考鏈路」的方式,看看能不能在一些提示下,激發模型調用潛在的推理能力呢?
我們在使用 LLM 時,或多或少都會發現----「咦?怎麼每次問一樣的問題,得到的回覆不會完全一樣呢?」
因為這些 LLM 在預訓練的時候使用的是"Next Token Predict"的方式訓練,可以理解成訓練模型玩「文字接龍遊戲」。訓練過的模型,它其實不是記住這句話後面要接哪一個特定的字,而是學會一個詞彙表的機率分佈。也就是說每次 chatgpt 都是根據詞彙表中所有字各自有多大的機率應該出現在當下輸入句子的後面,來選擇現在要輸出什麼文字。
如果我們現在選擇 "greedy decode" 的方式,那就每次都選擇機率最大的字當作輸出;如果選擇像是 "top p" 或是 "top k" 的 sampling 方式,那就是在機率最大的前 k 個字(token)中,隨機抽取一個字當作輸出。
當前 LLM 主流的 decode 策略多使用 top-p sampling,這也是為什麼我們問一樣的問題,不會得到完全一模一樣的回覆的原因,因為在解碼的過程中會引入一定程度的隨機性。
我們用李宏毅老師投影片中舉的例子來解釋:
(👆🏻圖片來源)
輸入「台灣大」這幾個字給模型,模型有可能輸出「學」,也有可能輸出「車隊」。
我們可以理解成模型在不同語境的訓練資料下學會「台灣大」後面可能接「學」也可能接「車隊」。如果現在模型從學校的思考角度出發,那「學」這個字的機率就會比較高;如果從計程車、交通的角度出發,那「車隊」的機率就會比較高。
不同的機率分佈,隱含模型從不同角度去思考當前這個輸入的語境有可能要配上什麼樣的文字接龍才會合理的假設。
回到我們的數學問題。
一道數學題目可能有多種不同的解法,從幾何角度出發、代數角度出發,或是常規解法、速解法等等,每個複雜的問題都可以由多種思路推導出正確的答案。
那如果我們先用 "Chain-of-Thoughts" 叫模型 "Think Step by Step." 把推理步驟都寫出來,並且採用不同的 sampling 方式,是不是就有機會 sample 到模型不同的思路,進而產生不同的解法呢?
如果不同的解法都產生一樣的答案,或是多數解法都產生某一個特定的答案,那是不是就代表這個答案非常可信了呢?
就好像我們考數學,寫完要檢查的時候,老師都會要我們嘗試用另外一種方式驗證答案,如果多種方式做下來的答案都一樣,那基本上就很有信心這題穩了。
這基本上就是 "Self-Consistency" 的思路:
簡單來說就是:通過COT生成多條推理路徑和相應的答案,最後選擇出現頻率最高的答案作為最終輸出。
怎麼樣?是不是很直覺呢?
接下來就讓我們來看看,結合昨天提到的讓模型「寫 code 來解題」與今天討論的 "Self-Consistency" 後,deepseek-math-7B
可以答出幾題呢?
既然是要多 sample 幾次產生不同推理路徑與答案,最後再挑出最常出現的答案當作最終答案。
那我們到底要 smaple 幾次呢?
我們先設定 sample 5 次觀察看看。
MODEL_PATH = "deepseek-ai/deepseek-math-7b-rl"
quantization_config = BitsAndBytesConfig(
load_in_4bit = True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
config = AutoConfig.from_pretrained(MODEL_PATH)
config.gradient_checkpointing = True
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, cache_dir=cache_dir)
model = AutoModelForCausalLM.from_pretrained(
MODEL_PATH,
device_map="auto",
torch_dtype="auto",
trust_remote_code=True,
# quantization_config=quantization_config,
config=config,
cache_dir=cache_dir
)
pipeline = transformers.pipeline(
"text-generation",
model=model,
tokenizer=tokenizer,
torch_dtype='auto',
device_map="auto",
)
設定我們的 instruction:
tool_instruction += '\nPlease integrate natural language reasoning with programs to solve the problem above, and put your final answer within \\boxed{}.'
由於答案會介在 0~999 之間,為了讓模型輸出的答案不要超出這個範圍,我們提醒模型要把最後的答案取 1000 的餘數再輸出;另外再要求模型寫出程式碼來解題,這樣我們就可以截取過程中模型寫的程式碼,額外執行這份程式碼去進行精確地數字運算(畢竟我們不太相信LLM的計算能力嘛~)。
接下來我們開始讓模型針對 df 中的每個問題都執行5次inference和decode:
n_repetitions = 5
total_results = []
total_answers = []
for i in tqdm(range(len(df))):
id_ = df['id'].loc[i]
problem = df['problem'].loc[i]
messages = [
{
"role": "user",
"content": problem + tool_instruction
}
]
query_prompt = tokenizer.apply_chat_template(
messages,
tokenize=False
)
results = []
answers = []
for _ in tqdm(range(n_repetitions)):
try:
raw_output = pipeline(
query_prompt,
max_new_tokens=2048,
do_sample=True,
temperature=0.7,
return_full_text=False
)
raw_output = raw_output[0]['generated_text']
result_output, code_output = process_output(raw_output)
torch.cuda.empty_cache()
gc.collect()
except Exception as e:
print(e)
result_output, code_output = -1, -1
results.append(result_output)
answers.append(code_output)
total_results.append(results)
total_answers.append(answers)
我們會紀錄 total_results
和 total_answer
:
total_results
用 regex 去 parsing 模型在每個回覆的末尾寫上的答案。total_answers
會擷取模型回覆中的程式碼,將這些程式碼集中到另一個 .py 的檔案,執行這個檔案得到跑出來的數值結果後,再紀錄這個結果到 list 裡面。要怎麼 parsing llm 回覆中的程式碼並另外創建一個檔案來執行呢?
下面 process_output()
作為主程式,負責做擷取程式碼、執行程式碼、擷取 llm 自己認為的答案三件事:
def process_output(output):
result = output
# 提取並執行程式碼
code = extract_code_from_output(output)
if code:
save_code_to_file(code)
code_output = execute_code()
else:
code_output = -1
print('CODE RESULTS', code_output)
# 解析數學公式
result_output = parse_result_boxed_expression(result)
final_result_output = calculate_result_output(result_output)
print('BOXED RESULT', final_result_output)
return final_result_output, code_output
擷取程式碼:
def extract_code_from_output(output):
"""從輸出中提取程式碼區塊"""
try:
code = output.split('```')[1][7:] # 假設提取的程式碼從第7個字符開始
return code
except IndexError:
print("ERROR: 無法提取程式碼")
return None
將程式碼保存到另外一份 code.py
檔案並執行它:
def save_code_to_file(code, filename='code.py'):
"""將程式碼保存到文件"""
try:
with open(filename, 'w') as fout:
fout.write(code)
except Exception as e:
print(f"ERROR: 無法保存程式碼到文件 {filename} - {e}")
def execute_code(filename='code.py', timeout=7):
"""執行程式碼並返回輸出"""
batcmd = f'timeout {timeout} {sys.executable} {filename}'
try:
shell_output = subprocess.check_output(batcmd, shell=True).decode('utf8')
return round(float(eval(shell_output))) % 1000
except Exception as e:
print(f"ERROR: 程式碼執行失敗 - {e}")
return -1
接下來是解析llm自己認為的答案:
def parse_result_boxed_expression(result):
"""解析 \boxed{} 內的表達式"""
try:
result_output = re.findall(r'\\boxed\{(.*)\}', result)
if not result_output:
return naive_parse(result)
return result_output[-1]
except Exception as e:
print(f"ERROR: 無法解析 \boxed 表達式 - {e}")
return None
def calculate_result_output(result_output):
"""計算解析後的結果"""
try:
if result_output:
return round(float(eval(result_output))) % 1000
return -1
except Exception as e:
print(f"ERROR: 無法計算結果 - {e}")
return -1
def naive_parse(answer):
"""從字符串中反向提取連續的數字字符"""
out = []
number_found = False
# 反向遍歷字符串
for char in reversed(answer):
if char.isdigit():
out.append(char) # 如果是數字,加入列表
number_found = True
elif number_found:
break # 如果已經找到數字,遇到非數字則停止
return ''.join(reversed(out)) # 將數字反轉回正常順序並返回
(以上代碼重構自這邊)
好了,所以現在每一題我們都有五份執行code跑出來的結果,以及五份llm自己輸出的答案,共有 10 個結果。
基本上如果 llm 有將題目正確轉換成可以執行的代碼,並且這個代碼還可以執行得到正確結果,我們就應該相信這個結果;如果該問題沒辦法簡單地轉成一個可執行的 program 或是這個 program 轉錯了不能執行,我們就參考 llm 回覆的答案。
因此最終每一題剩下五個結果,我們再從中選取出現次數最多的數字當作最終答案。
最終呢,使用 5 次 zero-shot self-consistency 配上 programing 的方式,我們在 trainset 的 10 題中答對 1 題;但是提交到 LB 上,發現 50 題public testset 能答對 13 題!
這可能意味 trainset 的題目是整體資料集中至少是難度中等的題目,而我們答對的那 13 題測試題目可能是相較比較簡單的。
上面是我們每一題 sample 5 次之後得到的結果,你可能會想問,如果 sample 越多次會答對越多道題目嗎?
重問次數 | Accuracy |
---|---|
2 | 0% |
3 | 10% |
4 | 0% |
5 | 10% |
6 | 10% |
7 | 10% |
8 | 10% |
9 | 10% |
10 | 10% |
咦好像也不一定?
而且就算我固定 sample 次數,換一個 random seed 結果好像又不一樣。
討論區也有網友發現,使用同樣的解題方法,也就是 self-consistency + programming,有人能在 testset 答對 23 題,有人只能答對 13 題。
這可能是因為每個人設定的 sample 次數不一樣,sample 次數越多、同個問題問模型越多遍再做多數決,越有可能得到正確答案、準確率越高。
......真的是這樣嗎?
因為訓練資料集只有 10 筆太少了,我們這次從 MATH 的 testset 抽取難度分別是 Level 4 和 Level 5 共 56 道題目出來做測試。
我們假設隨著 inference 的次數越多,準確率和穩定性應該越高。
這邊穩定性指的是多次 inference 得到的答案應該逐漸趨同,或是差異漸小。
你會發現,大概在每道題重複問個 20 次以上後, accuracy 就沒有什麼明顯的提升了,穩定性的改善也不明顯,持續到每道題目重複問 60 次後,得分的最大和最小差距仍然達到 8%;不過隨著重複詢問的次數增加,回答中出現正確答案的機率確實有一直在增加,不過因為不穩定性一直存在,要怎麼從中 identify 出正確答案仍然是一件很困難的事情。
總之,我們從上面實驗結果可以發現,僅靠增加推理次數並不能完全消除模型得分的波動,這可能會導致同一個方案在 leaderboard 多次提交後,得分會出現3到4分的差異,也因此 LB 上的分數變得更不可靠。
而且,重複問模型越多次問題,就需要越長的執行時間。也許我們可以「跳過一些非常困難的問題」,把多問幾次的機會留給其他比較簡單的問題。
例如模型如果要生成很多 token 來解釋某一題的解題思路的話,那這道題目可能就是非常困難的問題,也許再多問幾次也問不出正確答案。既然如此,我們不如跳過這些問題,將時間留給其他比較簡單的題目多問幾次可能就中了!
金牌解法應該要是能在 CV 和 LB 都穩定獲取高分的作法,我們可以合理推測,他們除了增加 inference 的次數外,必定還采用了其他技巧,僅靠多問模型幾次這種做法,沒辦法保證自己的準確率。
另外,也有人發現使用不同的 floating point types,例如:fp16, bf16 ,對 stability 也會有不同的影響。
可以觀察到當我們重複問模型的次數比較少時,BF16 的 range value 比 FP16 還要小,這代表 使用BF16 模型,在回答上的 stability 其實是比較高的。
(reference to [1])
今天已經預埋好多伏筆了,明天我們就會進入金牌作法的解析,大家明天見~
謝謝讀到最後的你,希望你會覺得有趣!
如果喜歡這系列,別忘了按下訂閱,才不會錯過最新更新,也可以按讚⭐️給我鼓勵唷!
如果有任何回饋和建議,歡迎在留言區和我說✨✨
(Kaggle - AI Mathematical Olympiad - Progress Prize 1 解法分享系列)