iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

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,0001000 甚至只差一個千分位逗號。正規化會先移除特殊 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+x2*x 亦然,(1,2,3)(1,2) 則會先因元素數量不同而拒絕。

目前允許的範圍刻意不放得太寬:數字、基本算術、sqrtpioo、單一字母變數,以及短的 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,讓其進行判斷。


上一篇
建立一個可重現的 reasoning baseline
下一篇
Verifier-first:先判對錯,再分析推理軌跡
系列文
模型真的會推理嗎?30 天從 Base Model 打造可驗證的小型推理模型20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言