推理模型从零构建 第3章:如何评估推理模型
1. 评估流程总览
评估一个数学推理模型,核心问题是:模型的答案和标准答案是否数学等价?这听起来简单,实际上充满挑战 — \frac{14}{3} 和 14/3 和 4.666... 是同一个数,但字符串完全不同。
书中将评估流程分为 6 步:
- 加载模型,对问题生成回答
- 从回答中提取
\boxed{...}中的最终答案 - 对提取的答案做 LaTeX 标准化(去掉格式噪音)
- 用 SymPy 解析为符号表达式
- 判断模型答案与标准答案是否数学等价
- 在 MATH-500 数据集上批量评估
2. 从 LLM 输出中提取答案
数学推理模型的标准做法是让模型把最终答案放在 \boxed{...} 中。提取函数需要处理嵌套大括号:
import re
def get_last_boxed(text):
"""提取文本中最后一个 \\boxed{...} 的内容,支持嵌套大括号"""
boxed_start_idx = text.rfind(r"\boxed")
if boxed_start_idx == -1:
return None
current_idx = boxed_start_idx + len(r"\boxed")
# 跳过空白,找到开始的 {
while current_idx < len(text) and text[current_idx].isspace():
current_idx += 1
if current_idx >= len(text) or text[current_idx] != "{":
return None
# 用计数器处理嵌套大括号
current_idx += 1
brace_depth = 1
content_start_idx = current_idx
while current_idx < len(text) and brace_depth > 0:
if text[current_idx] == "{":
brace_depth += 1
elif text[current_idx] == "}":
brace_depth -= 1
current_idx += 1
if brace_depth != 0:
return None
return text[content_start_idx:current_idx - 1]
RE_NUMBER = re.compile(r"-?(?:\d+/\d+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)")
def extract_final_candidate(text, fallback="number_then_full"):
"""优先提取 \\boxed 内容;否则 fallback 到最后一个数字"""
result = ""
if text:
boxed = get_last_boxed(text.strip())
if boxed:
result = boxed.strip().strip("$ ")
elif fallback in ("number_then_full", "number_only"):
m = RE_NUMBER.findall(text)
if m:
result = m[-1] # 取最后一个数字
elif fallback == "number_then_full":
result = text # 实在没有就返回全文
return result
# 测试
model_answer = r"... \boxed{\dfrac{14}{3}}"
print(extract_final_candidate(model_answer)) # \dfrac{14}{3}
ℹ️ 为什么用 rfind(从后往前找)?模型可能在推理过程中写出多个 \boxed{}(比如中间步骤也 box 了一个结果),我们只关心最后一个,通常那才是最终答案。
3. LaTeX 标准化
提取出来的答案通常带有各种 LaTeX 格式噪音。normalize_text 函数做了大量清洗工作,把五花八门的 LaTeX 写法统一为可以被 SymPy 解析的形式:
LATEX_FIXES = [ # LaTeX 格式替换规则
(r"\\left\s*", ""), # 去掉 \left
(r"\\right\s*", ""), # 去掉 \right
(r"\\,|\\!|\\;|\\:", ""), # 去掉间距命令
(r"\\cdot", "*"), # \cdot → *
(r"\\dfrac", r"\\frac"), # \dfrac → \frac(统一)
(r"\\tfrac", r"\\frac"), # \tfrac → \frac
]
def normalize_text(text):
if not text:
return ""
# 去掉 chat 特殊 token
text = re.sub(r"<\|[^>]+?\|>", "", text).strip()
# 去掉 \text{...} 包裹
match = re.match(r"^\\text\{(?P.+?)\}$", text)
if match:
text = match.group("x")
# 去掉数学分隔符 \( \) \[ \]
text = re.sub(r"\\\(|\\\)|\\\[|\\\]", "", text)
# 应用 LaTeX 替换规则
for pat, rep in LATEX_FIXES:
text = re.sub(pat, rep, text)
# \frac{a}{b} → (a)/(b)
text = re.sub(
r"\\frac\s*\{([^{}]+)\}\s*\{([^{}]+)\}",
lambda m: f"({m.group(1)})/({m.group(2)})", text
)
# \sqrt{x} → sqrt(x)
text = re.sub(
r"\\sqrt\s*\{([^}]*)\}",
lambda m: f"sqrt({m.group(1)})", text
)
# ^ → **(指数)
text = text.replace("^", "**")
# 去掉千分位逗号:1,234 → 1234
text = re.sub(r"(?<=\d),(?=\d\d\d(\D|$))", "", text)
return text.replace("{", "").replace("}", "").strip().lower()
# 示例
print(normalize_text(r"\dfrac{14}{3}")) # (14)/(3)
print(normalize_text(r"$\sqrt{8}/2$")) # sqrt(8)/2
这个函数处理了十几种边界情况:Unicode 上标(²→**2)、角度符号(°)、多选题标签("c. 3"→3)、混合数("2 1/3"→2+1/3)等。虽然看起来繁琐,但在批量评估 500 道题时,每一种边界情况都可能出现。
4. 符号等价判定与评分
标准化后的答案可能仍然表面不同但数学等价(比如 28/6 和 14/3)。书中用 SymPy 符号计算库来判定等价性:
from sympy.parsing import sympy_parser as spp
from sympy import simplify
def sympy_parser(expr):
"""将字符串解析为 SymPy 符号表达式"""
if expr is None or len(expr) > 2000:
return None # 防止 badly-trained 模型的超长垃圾输出
try:
return spp.parse_expr(
expr,
transformations=(
*spp.standard_transformations,
spp.implicit_multiplication_application, # "2x" → 2*x
),
evaluate=True, # 2+3 → 5
)
except (SyntaxError, TypeError, ValueError):
return None
def equality_check(expr_gtruth, expr_pred):
"""判断两个表达式是否数学等价"""
if expr_gtruth == expr_pred: # 快速路径:字符串相同
return True
gtruth = sympy_parser(expr_gtruth)
pred = sympy_parser(expr_pred)
if gtruth is not None and pred is not None:
try:
return simplify(gtruth - pred) == 0 # 差为0则等价
except (TypeError,):
pass
return False
# 测试
print(equality_check("(14)/(3)", "14/3")) # True
print(equality_check("0.5", "(1)/(2)")) # True
print(equality_check("(14)/(3)", "(15)/(3)")) # False
完整的 grade_answer 函数还需要处理元组答案(如坐标 (3, π/2)),它会把答案拆成多个部分逐一比较:
def grade_answer(pred_text, gt_text):
"""完整评分:标准化 → 拆分 → 逐部分等价判定"""
if pred_text is None or gt_text is None:
return False
gt_parts = split_into_parts(normalize_text(gt_text))
pred_parts = split_into_parts(normalize_text(pred_text))
# 部分数量必须一致
if not gt_parts or not pred_parts or len(gt_parts) != len(pred_parts):
return False
# 每个部分都必须数学等价
return all(
equality_check(gt, pred)
for gt, pred in zip(gt_parts, pred_parts)
)
# 元组答案测试
grade_answer(r"(14/3, 2/3)", "(14/3, 4/6)") # True(4/6 = 2/3)
grade_answer("(2, 1)", "(1, 2)") # False(顺序不同)
5. MATH-500 基准评估
MATH-500 是从 MATH 数据集中挑选的 500 道数学问题,覆盖代数、几何、数论、预备微积分等领域,难度 1-5 级。每道题都有标准答案,非常适合用来评估推理模型。
评估前需要一个 prompt template,告诉模型用 \boxed{} 格式输出答案:
def render_prompt(prompt):
return (
"You are a helpful math assistant.\n"
"Answer the question and write the final result on a new line as:\n"
"\\boxed{ANSWER}\n\n"
f"Question:\n{prompt}\n\nAnswer:"
)
def evaluate_math500_stream(model, tokenizer, device, math_data,
max_new_tokens=2048):
num_correct = 0
for i, row in enumerate(math_data, start=1):
# 1. 构造 prompt
prompt = render_prompt(row["problem"])
# 2. 生成回答
gen_text = generate_text_stream_concat(
model, tokenizer, prompt, device,
max_new_tokens=max_new_tokens
)
# 3. 提取答案
extracted = extract_final_candidate(gen_text)
# 4. 评分
is_correct = grade_answer(extracted, row["answer"])
num_correct += int(is_correct)
acc = num_correct / len(math_data)
print(f"Accuracy: {acc*100:.1f}% ({num_correct}/{len(math_data)})")
return acc
⚠️ Prompt template 对结果影响巨大:书中发现,仅仅把 "Question" 改成 "Problem",base 模型准确率从 20% 跳到 40%;不用 template 直接传原题,准确率竟然达到 70%。这说明 base 模型在训练数据中见过类似格式的数学题。但对 reasoning 模型反而起反作用(90% → 50%),因为 reasoning 模型是针对特定 chat template 训练的。
6. Base vs Reasoning 模型对比
书中在完整 500 题上的评估结果:
| 模型 | 设备 | MATH-500 准确率 | 评估耗时 |
|---|---|---|---|
| Qwen3 0.6B Base | CUDA (DGX Spark) | 15.6% | 10.0 min |
| Qwen3 0.6B Reasoning | CUDA (DGX Spark) | 50.8% | 182.2 min |
几个值得注意的点:
- Reasoning 模型慢 18 倍:因为它会在
<think>标签内生成长篇思考过程(chain-of-thought),每道题生成的 token 数远超 base 模型。 - 15.6% → 50.8% 是一个巨大的提升,但这个 0.6B 的小模型离 SOTA(90%+)还有不小差距。后续章节的目标就是通过各种技术缩小这个差距。
- 在不同硬件(CPU/MPS/CUDA)上,同一模型的准确率可能略有差异 — 这是浮点精度差异导致的,属于正常现象。
7. 关键收获
- 评估是推理研究的基础设施:没有可靠的评估体系,就无法判断任何推理增强技术是否有效。本章构建的
extract_final_candidate → normalize_text → grade_answer流水线将在后续所有章节中复用。 - 数学等价判定不是字符串比较:必须经过 LaTeX 标准化 + 符号解析才能正确判断。书中的
grade_answer通过了 16 个精心设计的测试用例,覆盖分数、根号、角度、元组等多种情况。 - Prompt engineering 很重要:不同的 prompt template 对同一模型可以产生数倍的准确率差异。这也暗示了后续章节中 reward shaping 和 prompt 设计的重要性。
- MATH-500 是贯穿全书的度量标准:后续的 inference-time scaling(第4章)、self-refinement(第5章)、GRPO 训练(第6-7章)都会用 MATH-500 来衡量效果。
- Base model 有潜力,但需要引导:Qwen3 0.6B base 在数学训练数据上见过不少题,但它缺乏结构化的推理能力。Reasoning model 通过 RL 训练获得了 "先思考再回答" 的能力,这就是后续章节要实现的目标。
ℹ️ 下一章预告:有了评估体系,第4章将探索不修改模型权重的推理增强方法 — inference-time scaling。通过 temperature 调节、Best-of-N 采样和多数投票(self-consistency),我们能在推理时用更多计算换取更高准确率。
本文是 Build a Reasoning Model (From Scratch) (Sebastian Raschka) 的学习笔记。所有配图版权归原作者所有。代码基于原书示例,有简化和中文注释。