Day 07 的 1000 題裡,模型答對了 178 題,從模型停止生成答案到 correct=True 之間,還隔著答案擷取、正規化和數學等價判定等步驟。任何一步寫得太寬鬆,都可能把錯誤答案放進來;寫得太嚴格,又會因為誤判記錄方式不同而排除正解。
Sebastian Raschka 在原書第三章正是依循這樣的想法,官方 Chapter 3 程式先從自由文字裡取出最後的 boxed answer,接著整理常見數學記法、判斷等價,最後才對整批資料評分。我們保留這樣的決策邏輯,把「答案正確」與「格式合規」拆開來紀錄,並且為無法安全處理的輸入留下明確的 unsupported 狀態。
第一步是找到 \boxed{...} 裡的內容,看似簡單,實際模型有可能在前面寫過一個答案,後來又自行修正,也可能在 box 裡放入 \frac{2}{6} 這種仍有大括號的 LaTeX;若只用一段尋找下一個 } 的 regular expression,遇到巢狀括號便會太早停止。因此實作會從最後一次出現的 \boxed 開始,逐字追蹤括號深度,直到最外層真正閉合。
def get_last_boxed(text):
marker = r"\boxed"
start = text.rfind(marker)
if start < 0:
return None
cursor = start + len(marker)
while cursor < len(text) and text[cursor].isspace():
cursor += 1
if cursor >= len(text) or text[cursor] != "{":
return None
content_start = cursor + 1
depth = 1
cursor += 1
while cursor < len(text) and depth:
depth += (text[cursor] == "{") - (text[cursor] == "}")
cursor += 1
if depth != 0:
return None
return text[content_start : cursor - 1]
例如下面這段輸出,最後候選答案是 7,不是 5。
Draft \boxed{5}; corrected \boxed{7}.
如果整段文字沒有完整的 box,程式才退回最後一個可以辨識的數字。Therefore the result is 4. 因此仍能被判定為答案 4;Final attempt: \boxed{4 雖然括號沒有閉合正確,也會從可見文字裡取到 4。兩者的 correct 都可能是 true,format_valid 卻必須維持 false。Fallback 是為了保留模型到底有沒有算對的訊號,不是替它補交格式作業。
找到候選答案以後,仍不能直接比字串。\dfrac{1}{2} 和 0.5 的字面完全不同,數學上卻相同;1,000 和 1000 甚至只差一個千分位逗號。正規化會先移除特殊 token 與多餘的數學外框,把 \dfrac 統一成 \frac,再處理分數、根號、乘號、次方和空白。
\dfrac{2}{4} -> ((2)/(4))
\sqrt{9} -> sqrt(9)
2^3 -> 2**3
1,000 -> 1000
這一步只能消除表面的書寫差異,不能證明兩個式子相等。((2)/(6)) 與 1/3 正規化後仍是兩個字串,所以 verifier 會先嘗試完全相同,再把支援的表示式交給 SymPy,檢查兩者相減後能否化簡為零。多部分答案則先拆成相同數量的元素,再逐一比較,因此 (1, 2) 和 [1,2] 也能在目前規則下視為相同。
def equivalent(prediction, reference):
pred_norm = normalize_math_text(prediction)
ref_norm = normalize_math_text(reference)
if pred_norm == ref_norm:
return True, "normalized_exact"
pred_parts = split_into_parts(pred_norm)
ref_parts = split_into_parts(ref_norm)
if len(pred_parts) != len(ref_parts):
return False, "different_part_count"
parsed = list(zip(map(safe_parse, pred_parts), map(safe_parse, ref_parts)))
if any(pred is None or ref is None for pred, ref in parsed):
return False, "unsupported_expression"
return all(sympy.simplify(pred - ref) == 0 for pred, ref in parsed), "sympy"
上面為了閱讀省略了回傳的欄位,真正留下的結果還包含正規化前後文字、使用哪一種判定方法,以及 parser 是否支援這個表示式。這個差別有其必要,因為\frac{2}{6} 對 1/3 會得到等價的結果,x+x 對 2*x 亦然,(1,2,3) 對 (1,2) 則會先因元素數量不同而拒絕。
目前允許的範圍刻意不放得太寬:數字、基本算術、sqrt、pi、oo、單一字母變數,以及短的 tuple 或 list。像 __import__(os) 這種輸入不會被當成 Python 執行,\sin(0) 即使對人類來說答案很清楚,也因為不在目前的函數清單裡而標記 unsupported。SymPy 官方文件明確提醒 parse_expr() 會使用 eval,不能直接接受尚未清理的輸入;所以這裡先限制字元、識別字和可用名稱,並提供沒有 builtins 的環境。
完整的評分函式最後會同時回傳候選答案、擷取方法、驗證方法、是否支援、答案對錯、格式狀態與錯誤類別。於是 \boxed{5} 對參考答案 4 是「格式正確、答案錯誤」;只有一句 Therefore the result is 4. 則是「答案正確、格式不合規」。如果連答案都抓不到,和抓得到但 verifier 不支援,也會分成兩種錯誤,而不是全部塞進同一個 false。
我為這個 pipeline 準備了 24 個固定案例,其中六個檢查正規化,十個檢查等價或明確拒絕,另外八個從一整段模型輸出走到最後的錯誤分類。執行結果是 24/24 全部符合事先寫好的預期。
normalization cases 6 / 6
verification cases 10 / 10
end-to-end cases 8 / 8
overall 24 / 24
這個 24/24 並非模型 benchmark,而是檢驗模型列出的輸入可得到固定、可追查的決定。昨天 baseline 裡那 164 題「答案正確、格式不合規」,就是靠這種把答案和格式正確與否分隔開來的判斷方式;另外 100 題 unsupported 也不意味著「100題數學都錯了」,只能說依照現在的評分契約,我們沒有足夠把握把它們算對。
所以,這就是 178 題答對的真正意義;然而 verifier 一旦不只拿來做報表,而要在 RLVR 裡直接發 reward,事情又會有所不同。模型若反覆遇到同一套規則,也有機會找到人類在最初設計上沒有考慮過的輸入,程式碼本身是一種離散狀態機,可能前面的狀態都好好的,突然下一個狀態就爆炸或超出我們的設計。因此,Day 09 預計會討論我們該輸入哪些內容給 verifier,讓其進行判斷。