推理模型从零构建 第3章:如何评估推理模型

📅 2026-03-02📖 ~8 min readReasoningEvaluationMATH-500
本文是 Build a Reasoning Model (From Scratch)(Sebastian Raschka 著)第3章的学习笔记。训练推理模型之前,必须先有可靠的评估体系。本章从零实现了一个完整的数学验证流程:从 LLM 输出中提取答案LaTeX 标准化SymPy 符号等价判定,最终在 MATH-500 基准上评估 base model 和 reasoning model 的差异。
← Ch2: Qwen3文本生成Ch4: Inference-Time Scaling →

1. 评估流程总览

评估一个数学推理模型,核心问题是:模型的答案和标准答案是否数学等价?这听起来简单,实际上充满挑战 — \frac{14}{3}14/34.666... 是同一个数,但字符串完全不同。

Evaluation pipeline overview

完整评估流程:生成 → 提取答案 → 标准化 → 符号等价判定 → 评分 (图源: Reasoning from Scratch)

书中将评估流程分为 6 步:

  1. 加载模型,对问题生成回答
  2. 从回答中提取 \boxed{...} 中的最终答案
  3. 对提取的答案做 LaTeX 标准化(去掉格式噪音)
  4. 用 SymPy 解析为符号表达式
  5. 判断模型答案与标准答案是否数学等价
  6. 在 MATH-500 数据集上批量评估

2. 从 LLM 输出中提取答案

数学推理模型的标准做法是让模型把最终答案放在 \boxed{...} 中。提取函数需要处理嵌套大括号:

Extract answer from boxed

从模型输出中提取 \boxed{…} 中的答案 (图源: Reasoning from Scratch)
Python — 答案提取
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 解析的形式:

Normalize answer

标准化步骤:去格式 → 统一分数写法 → 处理特殊符号 (图源: Reasoning from Scratch)
Python — LaTeX 标准化(核心逻辑)
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/614/3)。书中用 SymPy 符号计算库来判定等价性:

Python — 符号等价判定
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)),它会把答案拆成多个部分逐一比较:

Python — 评分函数
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 级。每道题都有标准答案,非常适合用来评估推理模型。

Evaluate model on MATH-500

在 MATH-500 上评估模型:逐题生成 → 提取 → 评分 → 统计准确率 (图源: Reasoning from Scratch)

评估前需要一个 prompt template,告诉模型用 \boxed{} 格式输出答案:

Python — Prompt 模板与评估循环
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)上,同一模型的准确率可能略有差异 — 这是浮点精度差异导致的,属于正常现象。
Complete evaluation pipeline

本章构建的评估流程将贯穿全书,作为衡量推理增强效果的标准 (图源: Reasoning from Scratch)

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) 的学习笔记。所有配图版权归原作者所有。代码基于原书示例,有简化和中文注释。

← Ch2: Qwen3文本生成Ch4: Inference-Time Scaling →

Related Posts