构建推理模型——评估推理模型

52 阅读20分钟

本章内容

  • 可靠地从 LLM 的回答中提取最终答案
  • 用符号数学求解器(像计算器一样)将 LLM 输出与参考解对比以校验正确性
  • 跑通一条完整的评测流水线:加载预训练模型、生成答案、并在数据集上打分

评测让我们能区分“听起来很有说服力”的 LLM 与“确实能正确解题”的 LLM。LLM 的评测方法很多,从衡量任务正确率到确保模型遵守安全规范不等。
本章聚焦一种**基于校验(verification-based)**的方法:让模型解数学题,然后用“计算器式”的实现把模型自己的答案与参考答案比对,检查是否正确。

这个校验器不只用于数学任务评测,还引入了**可验证奖励(verifiable rewards)**的理念——这是我们在第 5 章实现的、面向推理模型的强化学习方法的基础。(更多评测方法见附录 F。)

image.png

图 3.1 本书主题的心智图。本章覆盖评测方法(阶段 2),重点是实现校验器。

3.1 构建一个数学答案校验器

实践中评测训练后的 LLM 常见有四类方式:选择题、多步答案校验器、排行榜、LLM 评审(见图 3.1)。研究论文、技术报告、营销材料和模型卡中常会综合使用多种结果。
如图 3.1 所示,这些评测大致可分为两大类:基准式评测裁决式评测。四种方法各有场景,但对推理模型而言,“校验器”尤其关键。

以数学题为例:根据题目复杂度,数学题通常需要逐步推理才能求解;而它的评测却很直接——把最终答案与标准答案比对即可。在这种设定下,校验器提供了一种简单可靠的方式衡量模型的推理是否导向了正确结果。
本章聚焦把“校验器”作为一种基准式手段来评估数学题的答案正确性,见图 3.2。

image.png

图 3.2 在自由问答设置中,用基于校验的方法评估 LLM。模型生成自由形式的解题过程与框出的最终答案;系统提取该最终答案并与数据集的正确答案比较。

校验器会将提取出的答案与参考解对比(如图 3.2),通常会借助外部工具,如代码解释器或计算器程序。
虽然本节目标是评测,但“校验器”将在后文再次登场:它既是度量性能的方式,也是强化学习训练推理模型的反馈信号(见第 5 章)。

提示
想进一步了解,附录 F 涵盖了其它评测方式,如多选基准、基于偏好的排行榜、以及“LLM 充当裁判”的方案。

需要注意,校验器方法只适用于易于(最好是确定性地)校验的领域,如数学与代码。此外,它会引入额外的复杂度与依赖,部分评测负担也会转移到外部工具上。
不过,由于数学题既能程序化地产生无限变体,又天然需要逐步推理,因此它已成为推理模型评测与开发的基石任务

接下来的内容,我们将按图 3.3 所示的 8 个步骤,循序搭建一个数学校验器。

image.png

图 3.3 构建并应用数学校验器的分步流程。从加载预训练 LLM 开始,生成答案、提取与归一化、再与标准解比较;对是否通过校验打分,并在整个数据集(如 MATH-500)上重复,以评估总体性能。

下一节将从步骤 1 与 2(见图 3.3)开始:加载上一章介绍的预训练 LLM,并将其配置为能够生成答案。

3.2 加载预训练模型以生成文本

在本节中,我们按图 3.3 的步骤 1 与 2 实现校验器:加载上一章介绍的预训练 LLM,并将其配置为生成答案。这为后续步骤(提取、归一化与校验答案)打下基础。
我们将继续使用第 2 章用到的同一预训练基础模型。不过在完成本章后,你可以把清单 3.1 中的 WHICH_MODEL = "base" 改为 WHICH_MODEL = "reasoning",在同一数据集上评估一个已经训练过的推理模型。

清单 3.1 加载预训练模型

from pathlib import Path
import torch
from reasoning_from_scratch.ch02 import get_device
from reasoning_from_scratch.qwen3 import (
    download_qwen3_small, Qwen3Tokenizer,
    Qwen3Model, QWEN_CONFIG_06_B
)
 
device = get_device()
torch.set_float32_matmul_precision("high")  #A
 
# device = "cpu"  #B
 
WHICH_MODEL = "base"  #C
 
if WHICH_MODEL == "base":
    download_qwen3_small(
        kind="base", tokenizer_only=False, out_dir="qwen3"
    )
    tokenizer_path = Path("qwen3") / "tokenizer-base.json"
    model_path = Path("qwen3") / "qwen3-0.6B-base.pth"
    tokenizer = Qwen3Tokenizer(tokenizer_file_path=tokenizer_path)
 
elif WHICH_MODEL == "reasoning":
    download_qwen3_small(
        kind="reasoning", tokenizer_only=False, out_dir="qwen3"
    )
    tokenizer_path = Path("qwen3") / "tokenizer-reasoning.json"
    model_path = Path("qwen3") / "qwen3-0.6B-reasoning.pth"
    tokenizer = Qwen3Tokenizer(
        tokenizer_file_path=tokenizer_path,
        apply_chat_template=True,
        add_generation_prompt=True,
        add_thinking=True,
    )
 
else:
    raise ValueError(f"Invalid choice: WHICH_MODEL={WHICH_MODEL}")
 
model = Qwen3Model(QWEN_CONFIG_06_B)
model.load_state_dict(torch.load(model_path))
model.to(device)
 
USE_COMPILE = False  #D
if USE_COMPILE:
  torch._dynamo.config.allow_unspec_int_on_nn_module = True
  model = torch.compile(model)
 
#A 将精度从 "highest" 降到可用 Tensor Cores 的级别(若适用)
#B 如遇到设备兼容性问题,取消此行注释强制使用 CPU
#C 默认加载与第 2 章相同的基础模型
#D 可选:置为 True 启用模型编译

默认情况下(清单 3.1),我们加载基础模型,与第 2 章一致。可选的“reasoning”变体是 Qwen3 团队在基础模型之上用推理相关方法训练得到的(第 5 章会介绍这些训练方法)。此处提供“reasoning”选项,便于稍后将其评测结果与基础模型对比。

加载好模型后,我们可以调用第 2 章的文本生成函数来生成文本。为方便调试,我们不使用正文里的 generate_text_basic_stream,而采用练习 2.2 的轻微改版 generate_text_basic_stream_cache(其源码见附录 B 解决方案)。它会边生成边打印 token,长回答时不至于看起来“卡住”。用法见清单 3.2。

清单 3.2 生成模型输出

from reasoning_from_scratch.ch02_ex import (
    generate_text_basic_stream_cache
)
 
prompt = (  #A
    r"If $a+b=3$ and $ab=\tfrac{13}{6}$, "
    r"what is the value of $a^2+b^2$?"
)
 
input_token_ids_tensor = torch.tensor(  #B
    tokenizer.encode(prompt),
    device=device
).unsqueeze(0)  #C
 
all_token_ids = []
 
for token in generate_text_basic_stream_cache(  #D
    model=model,
    token_ids=input_token_ids_tensor,
    max_new_tokens=2048,
    eos_token_id=tokenizer.eos_token_id
):
    token_id = token.squeeze(0)  #E
    decoded_id = tokenizer.decode(token_id.tolist())
    print(  #F
        decoded_id,   
        end="",
        flush=True
    )
    all_token_ids.append(token_id)
 
#G
all_tokens = tokenizer.decode(all_token_ids)
 
#A 将数学题作为字符串提示词
#B 把提示词编码成模型可处理的 token ID
#C 增加 batch 维
#D 逐 token 流式生成输出
#E 去掉 batch 维
#F 实时打印每个生成的 token
#G 将全部生成的 token 解码为完整文本

运行清单 3.2,会先把一道简单数学题编码为 token,随后模型以流式方式逐个 token 生成并实时打印,我们也同时把生成的 token 收集起来,最后一次性解码得到完整答案字符串。这样既能实时查看生成过程,也能保存完整文本all_tokens)以便后续处理。

该代码生成的回答示例(节选)如下:

To find the value of (a2+b2)( a^2 + b^2 ) given that ( a+b=3a + b = 3 )
and ( ab=136ab = \frac{13}{6} ), we can use the following algebraic identity:
a2+b2=(a+b)22aba^2 + b^2 = (a + b)^2 - 2ab Step 1: Substitute the given values into the equation.
a2+b2=(3)22(136)a^2 + b^2 = (3)^2 - 2 \left( \frac{13}{6} \right) [...]
Final Answer:
143\boxed{\dfrac{14}{3}}

可以看到,即便是基础模型,也会给出类似推理模型的“过程解释”。这很可能是因为 Qwen3 团队在预训练阶段包含了 CoT(链式思维)数据(其技术报告中有说明)。不过,即使具备一定“类推理”行为,引入更多推理方法仍可进一步提升能力。(注意:不同硬件如 CPU/CUDA/MPS 上生成的措辞可能略有差异。)

如果你不熟悉常用于数学表达的 LaTeX 语法,上述原始文本可能不易阅读。可用 IPython 的 Latex 类进行渲染:

from IPython.display import Latex, display
display(Latex(all_tokens))

在笔记本中执行后,会得到如图 3.4 那样的渲染结果。图 3.4 给出的最终答案 (14/3) 确为本题正确解。

image.png

3.3 为文本生成实现一个便捷封装

上一节我们完成了预训练 LLM 的加载与文本生成功能(见图 3.5),这对应本章评测流程中的前两步。
为便于后续使用,这里给生成函数再包一层封装(清单 3.3),这样每次只需传入 modeltokenizerprompt 和少量参数,而不用反复编写编码与准备输入的样板代码。

image.png

清单 3.3 流式文本生成的便捷封装

def generate_text_stream_concat(
    model, tokenizer, prompt, device, max_new_tokens,
    verbose=False,
):
    input_ids = torch.tensor(                    #A
        tokenizer.encode(prompt), device=device  #A
        ).unsqueeze(0)                           #A
 
    generated_ids = []
    for token in generate_text_basic_stream_cache(  #B
        model=model,                                #B
        token_ids=input_ids,                        #B
        max_new_tokens=max_new_tokens,              #B
        eos_token_id=tokenizer.eos_token_id,        #B
    ):                                              #B
        next_token_id = token.squeeze(0)
        generated_ids.append(next_token_id.item())
 
        if verbose: #C
            print(
                tokenizer.decode(next_token_id.tolist()),
                end="",
                flush=True
            )
    
    return tokenizer.decode(generated_ids)  #D
#A 将提示词编码为 token 并放到正确设备上
#B 使用带 KV 缓存的流式生成
#C 可选:实时打印生成 token
#D 解码全部生成的 ID 得到最终字符串

使用示例:

generated_text = generate_text_stream_concat(
    model, tokenizer, prompt, device,
    max_new_tokens=2048,
    verbose=True  #A
)
#A 设为 False 可关闭逐 token 实时打印

输出与 3.2 节相同(节选):

[...]  # 省略中间过程

**Final Answer:**

[
\boxed{\dfrac{14}{3}}
]

3.4 提取“最终答案框”

现在模型已加载完毕,可以进入本章的核心:评估模型。
在上一节中,我们看到模型把最终答案放进了一个“答案框”里(原始文本形如 r"\boxed{\dfrac{14}{3}}"),即使我们并未显式要求这种格式。

模型采用这种格式作答,很可能是因为在预训练期间,它接触过带有类似格式的基准数据(包括 MATH-500)。一般而言,可以合理假设模型训练时互联网上能获取到的信息都可能进入了训练语料。

虽然在上一节并非必须,但在稍后用 MATH-500 数据集评估时,我们会在提示词中明确要求模型用这种带“方框”的最终答案格式返回结果。这是一个通用约定:既能让不同模型的评估更一致,也便于做数据抽取。

下面我们来编写代码,从模型输出中抽取被框起来的最终答案(如图 3.6 所示)。

image.png

图 3.6 从 LLM 输出中提取“方框答案”的示意。

本节实现图 3.6 的第 3 步;下一节将实现第 4 步的“归一化”。

由于你的机器不同(硬件差异会导致输出略有不同),我们先用一个硬编码的答案字符串来演示(假装这是模型生成的),稍后再让模型在 MATH-500 上实际生成并抽取。

model_answer = (
r"""... some explanation...
**Final Answer:**
 
[                     #A
\boxed{\dfrac{14}{3}}  #A
]                     #A
""")
#A 我们想要提取的“答案框”位置

说明
这里使用原始字符串r"""...""" 而不是 """..."""),这样 `` 不会被当作转义符,无需把反斜杠都写成 \

接下来,定义一个函数来从 model_answer 中抽取“答案框”内容。

清单 3.4 提取答案框

def get_last_boxed(text):
    boxed_start_idx = text.rfind(r"\boxed")  #A
    if boxed_start_idx == -1:
        return None
 
    current_idx = boxed_start_idx + len(r"\boxed")  #B
 
    #C
    while current_idx < len(text) and text[current_idx].isspace():
        current_idx += 1
 
    #D
    if current_idx >= len(text) or text[current_idx] != "{":
        return None
 
    current_idx += 1
    brace_depth = 1
    content_start_idx = current_idx
 
    #E
    while current_idx < len(text) and brace_depth > 0:
        char = text[current_idx]
        if char == "{":
            brace_depth += 1
        elif char == "}":
            brace_depth -= 1
        current_idx += 1
 
    
    if brace_depth != 0:  #F
        return None
 
    
    return text[content_start_idx:current_idx-1]  #G
#A 找到最后一个 "\boxed" 的位置
#B 移到 "\boxed" 之后
#C 跳过其后的空白字符
#D 期望紧跟着的是 "{"
#E 解析成对的大括号(支持嵌套)
#F 处理不成对的大括号
#G 返回最外层大括号内的内容

这个 get_last_boxed 小工具会从模型输出里,提取最后一个 \boxed{...} 的内容。具体做法是定位最终的 \boxed,跳过空白,检查花括号并处理嵌套,最终截取目标字符串。

虽然看上去有点繁琐,但在 MATH-500 这类基准上评测时非常值得:正确抽取最终答案是衡量推理能力的第一步。(MATH-500 是一个广泛使用的 500 题精挑细选题集,本章后续会用它来评测。)

现在来测试一下:

extracted_answer = get_last_boxed(model_answer)
print(extracted_answer)

输出应为 "\dfrac{14}{3}",即我们想要抽取的“框内答案”。

渲染数学公式
可以用前面介绍过的 IPython.display.Latex 来渲染;如果只是单个公式,也可以用更简便的 Math

from IPython.display import Math
display(Math(r"\dfrac{14}{3}"))

这会把分数渲染为 14/3。

虽然 get_last_boxed 工作正常,但我们再做一步健壮性封装:当不存在“最终答案框”或不完整时,能回退到其他候选(如下一个函数)。

清单 3.5 提取“最终答案候选”

import re
 
RE_NUMBER = re.compile(
    r"-?(?:\d+/\d+|\d+(?:.\d+)?(?:[eE][+-]?\d+)?)"
)
 
def extract_final_candidate(text, fallback="number_then_full"):
    
    result = ""  #A
 
    if text:  #B
        boxed = get_last_boxed(text.strip())
        if boxed:
            result = boxed.strip().strip("$ ")
 
        #C 
        elif fallback in ("number_then_full", "number_only"):
            m = RE_NUMBER.findall(text)
            if m:
                result = m[-1]  #D
            elif fallback == "number_then_full":
                
                result = text  #E
    return result
#A 默认返回空字符串
#B 优先尝试最后一个“答案框”
#C 若没有“答案框”,按回退策略
#D 取最后一个数字候选
#E 若也没有数字,按策略返回全文

extract_final_candidate 提供几种回退策略(当没有“答案框”时):

  • "number_then_full"(默认):先取最后一个简单数字;若没有则返回全文
  • "number_only":只取最后一个数字;若没有则返回空串 ""
  • "none":仅抽取“答案框”,若没有则返回空串 ""

这里使用了 Python 的 re 正则表达式识别数字(含分数、小数、科学计数法)。语法细节无需深究,关键是:当“答案框”缺失时,我们还能稳定地抽出“最后一个数字候选”。

试用一下:

print(extract_final_candidate(model_answer))

结果为 "\dfrac{14}{3}"。再试一个“带框”的变体:

print(extract_final_candidate(r"\boxed{ 14/3. }"))

返回 "14/3."(会去掉多余空白,但不会去掉尾部标点;稍后做等价性判断时会妥善处理)。

再试一个无框的场景(触发回退):

print(extract_final_candidate("abc < > 14/3 abc"))

默认回退策略能找到最后一个数字,正确返回 "14/3"

本节我们实现了从模型长回答中抽取最终答案的小工具。接下来要做的是把答案归一化成统一的“规范形式”,然后再与标准答案进行可校验的比较。

为什么不用另一个 LLM 来做抽取?
也可以让 LLM 去抽取“方框答案”,但那会引入不必要的复杂性与不确定性。抽取本身是一个机械、确定的任务:找到最后一个“方框”,或者回退到“最后一个数字/全文”。
用正则虽“看起来复杂”,但最终我们得到的是一个轻量、可复用、确定的小函数,不依赖另一个模型的波动输出。

3.5 对抽取到的答案做归一化

刚才从模型回答中抽出了 "\dfrac{14}{3}"。但同一个值可能被打印成 "\frac{14}{3}""14/3""$14/3$""(14)/(3)" 等多种样子。要实现一个健壮的校验系统,我们需要一个一致的比较方法。

本节实现图 3.7 的第 4 步:把不同格式的答案归一化为统一的“规范字符串”。

image.png

图 3.7 把 LLM 输出中的“方框答案”抽取出来,并转成规范的纯文本形式。之后将用它与标准答案做校验。

清单 3.6 答案归一化

LATEX_FIXES = [  #A
    (r"\left\s*", ""),
    (r"\right\s*", ""),
    (r"\,|\!|\;|\:", ""),
    (r"\cdot", "*"),
    (r"\u00B7|\u00D7", "*"),
    (r"\^\circ", ""),
    (r"\dfrac", r"\frac"),
    (r"\tfrac", r"\frac"),
    (r"°", ""),
]
 
RE_SPECIAL = re.compile(r"<|[^>]+?|>")  #B
 
def normalize_text(text):
    if not text:
        return ""
    text = RE_SPECIAL.sub("", text).strip()
 
    text = re.sub(r"^\s*{\s*\circ\s*}", "", text)  #C
    text = re.sub(r"^\s*\circ", "", text)            #C
    text = text.replace("°", "")                       #C
 
    match = re.match(r"^\text{(?P<x>.+?)}$", text)  #D
    if match:
        text = match.group("x")
 
    text = re.sub(r"\(|\)|\[|\]", "", text)  #E
 
    
    for pat, rep in LATEX_FIXES:  #F
        text = re.sub(pat, rep, text)
 
    #G
    text = text.replace("\%", "%").replace("$", "").replace("%", "")
    text = re.sub(
        r"\sqrt\s*{([^}]*)}",
        lambda match: f"sqrt({match.group(1)})",
        text,
    )
    text = re.sub(
        r"\sqrt\s+([^\\s{}]+)",
        lambda match: f"sqrt({match.group(1)})",
        text,
    )
 
    #H
    text = re.sub(
        r"\frac\s*{([^{}]+)}\s*{([^{}]+)}",
        lambda match: f"({match.group(1)})/({match.group(2)})",
        text,
    )
    text = re.sub(
        r"\frac\s+([^\s{}]+)\s+([^\s{}]+)",
        lambda match: f"({match.group(1)})/({match.group(2)})",
        text,
    )
 
    #I
    text = text.replace("^", "**")
    text = re.sub(
        r"(?<=\d)\s+(\d+/\d+)",
        lambda match: "+" + match.group(1),
        text,
    )
 
    #J
    text = re.sub(
        r"(?<=\d),(?=\d\d\d(\D|$))",
        "",
        text,
    )
 
    return text.replace("{", "").replace("}", "").strip().lower()
 
#A 要替换的 LaTeX 片段(左:原,右:替换后)
#B 去掉类似 "<|assistant|>" 等特殊聊天标记
#C 移除角度符号(° 与 ^{\circ} 等)
#D 若整体被 \text{...} 包裹则解包
#E 去掉 ( ) 与 [ ] 这种行内/行间数学包裹
#F LaTeX 规范化替换
#G 规范百分号、去 $,把 \sqrt{a} / \sqrt a 变为 sqrt(a)
#H 把 \frac{a}{b} / \frac a b 变为 (a)/(b)
#I 处理幂符号与带分数(如 “3 1/2” → “3+1/2”)
#J 去除数字中的千分位逗号

normalize_text 会把抽取到的答案改写成统一、可比较的形式:先清除特殊 token 和无用的 LaTeX 装饰(如 \left \right、角度记号),再解包 \text{...}、去掉行内/行间数学包裹,把常见结构改写成“计算器风格”,例如 \sqrt{a}sqrt(a)\frac{a}{b}(a)/(b);最后规范幂、带分数与千分位等,去掉大括号并统一大小写。这样,模型可能输出的多种 LaTeX 形式就被化成统一的字符串,便于后续做等价性校验。

试试对我们的模型答案做归一化:

print(normalize_text(extract_final_candidate(model_answer)))

不再是 r"\dfrac{14}{3}",而是统一成:

"(14)/(3)"

再试一个不同写法:

print(normalize_text(r"\text{[\frac{14}{3}]}"))

结果仍为 "(14)/(3)",符合预期。

至此,我们已具备从 LLM 长回答中抽取归一化最终答案的能力。下一节将实现“与标准答案比较”的函数,以便检查模型是否答对。

3.6 验证数学等价性

到目前为止,本章已实现:让 LLM 生成答案、抽取关键信息并做归一化。下一步,如图 3.8 所示,是将抽取出的答案与参考正确答案(ground truth)进行比较。

image.png

图 3.8 将 LLM 生成的答案与数据集提供的正确答案进行比对的示意。先抽取并归一化“最终答案”,再与标准答案比较;一致则判定为正确。

注意:若直接用 Python 的 == 做字符串比较,会把等价的表达判成不相等,比如 "14/3""(14)/(3)",以及 "(28)/(6)""(14)/(3)"
因此我们在等价性判断中引入一个中间步骤:把“抽取并归一化后的答案”交给符号数学引擎解析。

我们使用开源数学库 SymPy(sympy.org)。解析函数见清单 3.7。

说明
若你还没按第 2 章安装依赖,可单独安装:uv pip install sympy(或 uv add sympy)。

清单 3.7 用 SymPy 解析表达式以便做等价性判断

from sympy.parsing import sympy_parser as spp
from sympy.core.sympify import SympifyError
from tokenize import TokenError
 
def sympy_parser(expr):
    try:
        return spp.parse_expr(
            expr,
            transformations=(
                *spp.standard_transformations,  #A
                #B
                spp.implicit_multiplication_application,
            ),
 
            evaluate=True,  #C
        )
    except (SympifyError, SyntaxError, TypeError, IndexError, TokenError):
        return None
#A 标准变换(括号等常见语法)
#B 允许省略乘号(如 2y 视为 2*y)
#C 解析时做简化(如 2+3 -> 5)

sympy_parser 接受一个(已归一化的)字符串表达式并转成可比较的 SymPy 对象:应用标准解析规则,支持隐式乘法,并做基本算术化简。

说明
这里捕获的错误看似很多,但确实在对 MATH-500 全量 500 题评测时都遇到过,因为模型输出并不总是完美格式化。

试运行:

print(sympy_parser(normalize_text(
    extract_final_candidate(model_answer)
)))

返回分数 14/3。再试一个“未归一化”的分数:

print(sympy_parser("28/6"))

同样返回 14/3

现在基于 sympy_parser 实现等价性判断函数(清单 3.8):

清单 3.8 基于 SymPy 的等价性判断

from sympy import simplify
 
def equality_check(expr_gtruth, expr_pred):
    if expr_gtruth == expr_pred:  #A
        return True
 
    #B
    gtruth, pred = sympy_parser(expr_gtruth), sympy_parser(expr_pred)
 
    if gtruth is not None and pred is not None:  #C
        try:
            return simplify(gtruth - pred) == 0  #D
        except (SympifyError, TypeError):
            pass
 
    return False
#A 先做字符串完全相等的快速判断
#B 否则转为 SymPy 表达式
#C 两边都解析成功才继续
#D 差为 0 则数学等价

equality_check:先做字符串相等的快速路径;若不同,则解析成 SymPy 表达式并判断差是否为 0,从而识别“外观不同但数学等价”的答案(如 14/328/6)。

试验:

print(equality_check(
    normalize_text("13/4."),
    normalize_text(r"(13)/(4)")
))

返回 True。再试小数与分数的等价:

print(equality_check(
    normalize_text("0.5"),
    normalize_text(r"(1)/(2)")
))

也返回 True。再试一个负例:

print(equality_check(
    normalize_text("14/3"),
    normalize_text("15/3")
))

返回 False

到这里看起来很稳健。但再试一个元组场景:

print(equality_check(
    normalize_text("(14/3, 2/3)"),
    normalize_text("(14/3, 4/6)")
))

数学上应为 True2/34/6 等价),但当前函数返回 False,因为它只处理单一表达式,无法处理元组。我们将在下一节修复这一点。

3.7 评分(分题、多元组等)

我们将基于上节的等价性判断,扩展出一个更健壮的评分函数,支持“元组/列表样式”的答案,比如正确比较 "(14/3, 2/3)""(14/3, 4/6)"

首先写一个辅助函数,把“看起来像元组/列表”的表达式拆成子部分(清单 3.9)。

清单 3.9 拆分“元组/列表样式”表达式

def split_into_parts(text):
    result = [text]
 
    if text:  #A
        if (
            len(text) >= 2
            and text[0] in "([" and text[-1] in ")]"
            and "," in text[1:-1]
        ):
            items = [p.strip() for p in text[1:-1].split(",")]  #B
            if all(items):
                result = items
    else:  #C
        result = []
 
    return result
 
#A 粗判是否像 "(a, b)""[a, b]"
#B 逗号切分并去空白
#C 空串则返回空列表

测试:

split_into_parts(normalize_text(r"(14/3, 2/3)"))

返回 ['14/3', '2/3']

现在实现评分函数 grade_answer(清单 3.10):若两边是元组/列表样式,则拆分并对子项一一比对;否则按单表达式处理。

清单 3.10 评分函数:用等价性判断逐项比对

def grade_answer(pred_text, gt_text):
    result = False   #A
    if pred_text is not None and gt_text is not None:  #B
        gt_parts = split_into_parts(
            normalize_text(gt_text)
        )
        pred_parts = split_into_parts(
            normalize_text(pred_text)
        )
 
        if (gt_parts and pred_parts                #C
           and len(gt_parts) == len(pred_parts)):  #C
            result = all(
                equality_check(gt, pred)
                for gt, pred in zip(gt_parts, pred_parts)
            )  #D
 
    return result  #E
 
#A 默认当作不通过
#B 两边都非空才继续
#C 子项数量一致才有可比性
#D 逐项做数学等价判断,全部通过才算正确
#E 返回最终判定

grade_answer 可视为 equality_check 的“增强版”:先做归一化与拆分,再逐项用等价性判断。

  • 简单表达式,效果等同于 equality_check

    grade_answer("14/3", r"\frac{14}{3}")
    
  • 元组/列表,也能正确判断:

    grade_answer(r"(14/3, 2/3)", "(14/3, 4/6)")
    

进一步,我们准备一组更全面的测试用例(清单 3.11):

清单 3.11 测试用例与表格化输出

tests = [  #A
        ("check_1", "3/4", r"\frac{3}{4}", True),
        ("check_2", "(3)/(4)", r"3/4", True),
        ("check_3", r"\frac{\sqrt{8}}{2}", "sqrt(2)", True),
        ("check_4", r"( \frac{1}{2} + \frac{1}{6} )", "2/3", True),
        ("check_5", "(1, 2)", r"(1,2)", True),
        ("check_6", "(2, 1)", "(1, 2)", False),
        ("check_7", "(1, 2, 3)", "(1, 2)", False),
        ("check_8", "0.5", "1/2", True),
        ("check_9", "0.3333333333", "1/3", False),
        ("check_10", "1,234/2", "617", True),
        ("check_11", r"\text{2/3}", "2/3", True),
        ("check_12", "50%", "1/2", False),
        ("check_13", r"2\cdot 3/4", "3/2", True),
        ("check_14", r"90^\circ", "90", True),
        ("check_15", r"\left(\frac{3}{4}\right)", "3/4", True),
    ]
 
 
def run_demos_table(tests):
    header = ("Test", "Expect", "Got", "Status")
    rows = []
    for name, pred, gtruth, expect in tests:
        got = grade_answer(pred, gtruth)  #B
        status = "PASS" if got == expect else "FAIL"
        rows.append((name, str(expect), str(got), status))
 
    data = [header] + rows
    
    col_widths = [  #C
        max(len(row[i]) for row in data)
        for i in range(len(header))
    ]
 
    for row in data:  #D
        line = " | ".join(
            row[i].ljust(col_widths[i])
            for i in range(len(header))
        )
        print(line)
 
    passed = sum(r[3] == "PASS" for r in rows)  #E
    print(f"\nPassed {passed}/{len(rows)}")     #E
 
#A (名字, 预测, 标准答案, 期望结果)
#B 调用评分函数
#C 计算列宽,便于排版
#D 逐行打印
#E 汇总通过数

运行 run_demos_table(tests) 得到:

Test     | Expect | Got   | Status
check_1  | True   | True  | PASS  
check_2  | True   | True  | PASS  
check_3  | True   | True  | PASS  
check_4  | True   | True  | PASS  
check_5  | True   | True  | PASS  
check_6  | False  | False | PASS  
check_7  | False  | False | PASS  
check_8  | True   | True  | PASS  
check_9  | False  | False | PASS  
check_10 | True   | True  | PASS  
check_11 | True   | True  | PASS  
check_12 | False  | False | PASS  
check_13 | True   | True  | PASS  
check_14 | True   | True  | PASS  
check_15 | True   | True  | PASS  
 
Passed 15/15

如上所示,grade_answer 在多种格式与场景下都较为稳健。

练习 3.1:补充更多测试用例
尝试构造更刁钻的用例加入 run_demos_table(),看看能否找出误判或漏判的情况。

至此,评分函数已就绪;接下来我们将载入一个数学数据集,对 LLM 进行系统化评测。

3.8 载入评测数据集

正如本章迄今所示,要实现一个健壮的验证流水线并不轻松。好在我们已经具备了从答案抽取到打分的全部组件,可以开始在一个基准数据集上评测 LLM。正如图 3.9 所示,我们将使用广泛用于推理模型评测的 MATH-500 数据集(huggingface.co/datasets/Hu…)。它是从原始 MATH 数据集中精选出的 500 道题目。

image.png

图 3.9 载入评测数据集。前面的小节已在单题上完成步骤 2–6(生成、抽取、归一化、验证并打分)。接下来两步是载入完整数据集(步骤 7),并将同样流程应用到所有题目上以评估模型(步骤 8)。

我们用下面的代码载入 MATH-500(对应图 3.9 的步骤 7):

import json
from urllib.request import urlopen
 
local_path = Path("math500_test.json")
url = (
    "https://raw.githubusercontent.com/rasbt/reasoning-from-scratch/"
    "main/ch03/01_main-chapter-code/math500_test.json"
)
 
if local_path.exists():
    with local_path.open("r", encoding="utf-8") as f:
        math_data = json.load(f)
else:
    with urlopen(url) as f:
        math_data = json.load(f)
 
print("Number of entries:", len(math_data))

输出:

Number of entries: 500

从 Hugging Face Hub 载入数据集

MATH-500 的划分最初由 PRM800K 仓库提出(github.com/openai/prm8…),也可在 Hugging Face Hub 获取(huggingface.co/datasets/Hu…)。但为保证可复现性,本文从代码仓库提供的副本加载,以避免外部源变更带来的影响。

若你更倾向于直接从 Hugging Face 下载,可用如下代码(需先安装 datasetspip install datasetsuv add datasets):

from datasets import load_dataset
dset = load_dataset("HuggingFaceH4/MATH-500", split="test")
# 此处仅供参考,无需在本节运行

在进入下一节实现模型评测流水线之前,我们先用 pprint 查看数据结构,打印第一条样本:

from pprint import pprint
pprint(math_data[0])

输出示例:

{'answer': '\left( 3, \frac{\pi}{2} \right)',
 'level': 2,
 'problem': 'Convert the point $(0,3)$ in rectangular coordinates to polar '
            'coordinates.  Enter your answer in the form $(r,\theta),$ where '
            '$r > 0$ and $0 \le \theta < 2 \pi.$',
 'solution': 'We have that $r = \sqrt{0^2 + 3^2} = 3.$  Also, if we draw the '
             'line connecting the origin and $(0,3),$ this line makes an angle '
             'of $\frac{\pi}{2}$ with the positive $x$-axis.\n'
             '\n'
             '[asy]\n'
             'unitsize(0.8 cm);\n'
             '\n'
             'draw((-0.5,0)--(3.5,0));\n'
             'draw((0,-0.5)--(0,3.5));\n'
             'draw(arc((0,0),3,0,90),red,Arrow(6));\n'
             '\n'
             'dot((0,3), red);\n'
             'label("$(0,3)$", (0,3), W);\n'
             'dot((3,0), red);\n'
             '[/asy]\n'
             '\n'
             'Therefore, the polar coordinates are $\boxed{\left( 3, '
             '\frac{\pi}{2} \right)}.$',
 'subject': 'Precalculus',
 'unique_id': 'test/precalculus/807.json'}

可见每条样本是一个字典,关键字段包括:

  • "problem" :给 LLM 的题目文本;
  • "answer" :标准答案(ground truth),用于与模型答案比对;
  • "solution" :题目解析(本章不使用,但对训练或分析有用)。

至此,我们已具备:一个预训练 LLM、评测函数,以及一个基准数据集。接下来即可实现完整的模型评测流程。

3.9 评估模型

本节我们将把图 3.10 中步骤 2–6 的文本生成与评估工具真正跑起来,并把它们应用到上一节已加载的 MATH-500 数据集上(对应图 3.10 的步骤 8)。

image.png

图 3.10 在 MATH-500 上的完整评估流水线。加载数据集(步骤 7)后,把步骤 2–6 系统化地应用到所有题目上,得到最终评估结果(步骤 8)。

回忆第 3.4 节(抽取最终答案框),我们的答案检查流水线假设模型会用「盒装」格式返回答案(boxed),这在对数学推理模型进行评测时是常见约定。为提升模型遵循该格式的概率,我们可以按清单 3.12 所示来构造提示词模板:

清单 3.12 生成数学评测提示词的模板函数

def render_prompt(prompt):
    template = (
        "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:"
    )
    return template

现在把清单 3.12 的模板用于本章前面(第 3.2 节)的示例题目。为方便起见,这里重申该题目:

prompt = (
    r"If $a+b=3$ and $ab=\tfrac{13}{6}$, "
    r"what is the value of $a^2+b^2$?"
)
prompt_fmt = render_prompt(prompt)
print(prompt_fmt)

格式化后的提示词如下:

You are a helpful math assistant.
Answer the question and write the final result on a new line as:
\boxed{ANSWER}

Question:
If $a+b=3$ and $ab=\tfrac{13}{6}$, what is the value of $a^2+b^2$?

Answer:

接着,把该提示词交给第 3.3 节清单 3.3 中的文本生成封装函数,以便在实现最终评估函数之前先回顾一下生成流程:

generated_text = generate_text_stream_concat(
    model, tokenizer, prompt_fmt, device,
    max_new_tokens=2048,
    verbose=True
)

在这个提示词下,模型会给出一个相对简短的答案:“\boxed{10}”。(注意不同硬件如 CPU、CUDA 或 MPS 上的结果可能略有不同。)

虽然简短的答案能减少 token、加快生成,但该答案是错误的。相比之下,第 3.3 节中不使用模板时,模型会产出更长的推理过程,并得到正确答案 14/3。

不过,某个提示词模板是否适配某个模型与任务,最好在更大的样本集上评估后再下结论,例如本节稍后即将跑的 MATH-500。

关于提示词模板的选择

清单 3.12 的模板只是为了演示如何实现“自动判对”的评估流水线。它鼓励短输出,有助于你首次阅读本章时更快跑通。之后,建议基于推理版模型重新调整设置以优化准确率。

实践中,“不使用模板”会让 base 模型准确率提升约 50%,但却会让 reasoning 模型准确率下降约 40%。
还可以尝试其它模板。例如把清单 3.12 中的 “Question:” 换成 “Problem:” 是 MATH-500 的常见标准提示,这个看似细微的变化能让 base 模型准确率提升约 20%(很可能是更贴近其记忆中的训练文本——假设 MATH-500 测试集被包含在训练语料中)。然而,这对 reasoning 模型的准确率会下降约 30%。

在实现最终评估函数之前,我们先用一个更小的示例端到端跑一遍评估流程,见清单 3.13:

清单 3.13 小型演示函数:端到端评估流水线

def mini_eval_demo(model, tokenizer, device):
    ex = {  #A
        "problem": "Compute 1/2 + 1/6.",
        "answer": "2/3"
    }
    prompt = render_prompt(ex["problem"])     #B
    gen_text = generate_text_stream_concat(   #C
        model, tokenizer, prompt, device,     #C
        max_new_tokens=64,                    #C
    )                                         #C
    pred_answer = extract_final_candidate(gen_text)  #D
    is_correct = grade_answer(                       #E
        pred_answer, ex["answer"]                    #E
    )                                                #E
    
    print(f"Device: {device}")
    print(f"Prediction: {pred_answer}")
    print(f"Ground truth: {ex['answer']}")
    print(f"Correct: {is_correct}")
 
#A 测试样例(含 problem 与 answer 字段)
#B 1. 应用提示词模板
#C 2. 生成模型回答
#D 3. 抽取并归一化答案
#E 4. 打分

mini_eval_demo 把我们已实现的各组件串起来:套模板、生成、抽取与归一化、打分。它从一个 toy 样例开始,渲染提示词,流式生成,再解析出最终候选答案并与标准答案比对,最后打印结果。

调用 mini_eval_demo(model, tokenizer, device) 输出:

Device: mps
Prediction: 1/3
Ground truth: 2/3
Correct: False

可以看到,虽然成功抽取了 “1/3”,但与标准答案 “2/3” 不一致,因此判为 False。(不同硬件上结果可能不同。)

接下来,把这个流程用于 MATH-500。

清单 3.14 在 MATH-500 上的端到端评估流水线

import time
 
def evaluate_math500_stream(
    model,
    tokenizer,
    device,
    math_data,
    out_path=None,
    max_new_tokens=512,
    verbose=False,
):
 
    if out_path is None:
        dev_name = str(device).replace(":", "-")   #A
        out_path = Path(f"math500_{WHICH_MODEL}-{dev_name}.jsonl")
 
    num_examples = len(math_data)
    num_correct = 0
    print(f"MATH-500: 0/{num_examples}", end="\r", flush=True)
 
    start_time = time.time()
    
    with open(out_path, "w", encoding="utf-8") as f:   #B
        for i, row in enumerate(math_data, start=1):
            prompt = render_prompt(row["problem"])     #C
            gen_text = generate_text_stream_concat(    #D
                model, tokenizer, prompt, device,      #D
                max_new_tokens=max_new_tokens,         #D
                verbose=verbose,                       #D
            )
 
            extracted = extract_final_candidate(       #E
                gen_text                               #E
            )                                          #E
            is_correct = grade_answer(                 #F
                extracted, row["answer"]               #F
            )                                          #F
            num_correct += int(is_correct)
 
            record = {                                 #G
                "index": i,                            #G
                "problem": row["problem"],             #G
                "gtruth_answer": row["answer"],        #G
                "generated_text": gen_text,            #G
                "extracted": extracted,                #G
                "correct": bool(is_correct),           #G
            }                                          #G
            f.write(json.dumps(record, ensure_ascii=False) + "\n")
 
            if verbose:                                #H
                print(
                    f"\n\n{'='*50}\nMATH-500: {i}/{num_examples}\n"
                    f"{'='*50}\nExtracted: {extracted}\n"
                    f"Expected:  {row['answer']}\n"
                    f"Correct so far: {num_correct}\n{'-'*50}"
                )
            else:
                print(
                    f"MATH-500: {i}/{num_examples}",
                    end="\r", flush=True
                )
 
    #I
    seconds_elapsed = time.time() - start_time
    acc = num_correct / num_examples if num_examples else 0.0
    print(f"\nAccuracy: {acc*100:.1f}% ({num_correct}/{num_examples})")
    print(f"Total time: {seconds_elapsed/60:.1f} min")
    print(f"Logs written to: {out_path}")
    return num_correct, num_examples, acc
 
#A 生成与设备相关的、跨平台友好的文件名
#B 将每条样例的结果写入日志文件,便于后续分析
#C 1. 应用提示词模板
#D 2. 流式生成
#E 3. 抽取并归一化答案
#F 4. 与标准答案打分
#G 记录关键信息到 JSONL
#H 在生成过程中打印进度或详细输出
#I 打印汇总统计

evaluate_math500_stream 与清单 3.13 的小型演示采用相同主流程:对每道题渲染提示词、生成、抽取、打分。与小型演示不同的是,它会遍历数据集中的多条样例,并把所有生成与判分信息保存为 JSONL,便于复盘。

我们先在一个子集(前 10 题)上运行。该过程在一台搭载 M4 芯片的 Mac Mini 上约需 0.7 分钟。(评估推理版模型会更慢,因为其输出更长。)

print("Model:", WHICH_MODEL)
num_correct, num_examples, acc = evaluate_math500_stream(
    model, tokenizer, device, 
    math_data=math_data[:10],  #A
    max_new_tokens=2048,
    verbose=False              #B
)
#A 仅评估前 10 道题
#B 如需查看实时生成,可设为 True

此处将 max_new_tokens 设为 2048,是因为推理版模型有意生成更长的中间推理过程,不希望过早截断。但这也会显著拉长评测时间。如果看起来“卡住”,可把 verbose=True 打开,观察 token 级别的流式输出。

运行结果示例:

Model: base
Device: mps
MATH-500: 10/10
Accuracy: 20.0% (2/10)
Total time: 0.7 min
Logs written to: math500_base-mps.jsonl

可见 base 模型的准确率较低(20%)。打开 math500_base-mps.jsonl 可进一步分析:虽然每道题的答案都成功抽取,但大多错误,说明该 base 模型的数学解题能力尚弱——这也符合预期。

以编程方式读取 .jsonl

.jsonl 表示“每行一个 JSON 条目”。你可以直接用文本编辑器查看;也可以用 Python 载入:

dev_name = str(device).replace(":", "-")
local_path = f"math500_{WHICH_MODEL}-{dev_name}.jsonl"
results = []
with open(local_path, "r") as f:
    for line in f:
        if line.strip():
            results.append(json.loads(line))

如果把第 3.2 节清单 3.1 里的 WHICH_MODEL 设为 "reasoning",在同样 10 题子集上,reasoning 模型可达约 90% 准确率;在完整 500 题上,约为 50.8%。见表 3.1。

表 3.1 不同设备上的 MATH-500 任务准确率

模式设备准确率评测规模
BaseCPU30%10
BaseCUDA30%10
BaseMPS20%10
ReasoningCPU90%10
ReasoningCUDA90%10
ReasoningMPS80%10
BaseCUDA15.3%500
ReasoningCUDA50.8%500

如表所示,推理版的确能显著提高准确率,但代价是更高的计算与更长的生成时间(在 Mac Mini M4 上 10 题从 0.7 分钟升至 7 分钟;在 H100 上 500 题从 13.3 分钟升至 185.4 分钟),这正是使用推理模型的典型权衡。

提示

代码仓库提供了一个批处理脚本(github.com/rasbt/reaso…),可一次前向处理多个样例以加速评估(会占用更多显存/内存)。在 H100 上把 batch size 设为 128,评估 500 题的 base 模型总时长可从 13.3 分钟降至 3.3 分钟;reasoning 模型则从 185.4 分钟降至 14.6 分钟。脚本同样适配其它 GPU。

练习 3.2:统计平均响应长度
修改本章代码,让评估函数(清单 3.13)同时统计并输出平均响应长度。你也可以不改函数,直接从生成的 JSON 报告文件里计算。

练习 3.3:扩展或更换评测数据集
本章为节省算力只评估了 10 题。建议你在更大子集甚至全量上运行,观察 10 题子集是否具有代表性;也可以尝试你自己的数据。(参考:在 H100 上,完整 500 题的 basereasoning 分别约需 xxx 与 xxx 分钟。)

练习 3.4:尝试不同提示词模板
模型对提示词模板很敏感。尝试替换清单 3.11(应为 3.12)的模板,观察准确率变化。尽管 Qwen3 团队建议 base 模型无需 chat 模板,你也可以在清单 3.1 的 tokenizer 中启用 apply_chat_template=True,看看是否能提升 base 模型的表现。

至此,本章关于“基于验证器的数学任务评测”告一段落(见图 3.11)。我们选择数学,是因为该领域天然适合可验证奖励(verifiable reward),而这正是第 5 章要实现的“用于推理模型训练的强化学习”的基础。同样的思想亦可扩展到代码等领域,但那需要额外的安全执行环境配置,本章未展开。

image.png

图 3.11 本书主题的心智模型。本章实现了基于验证器的评估流水线。下一章将通过更高级的推理(文本生成)技巧提升模型的推理能力。

3.10 小结(摘要翻译)

  • 评测 LLM 的四类主要方法:选择题(multiple choice)、验证器(verifiers)、排行榜(leaderboards)以及由 LLM 充当评审(LLM judges)。
  • 基于验证器的评测允许自由生成答案,并借助外部工具来判定正误。
  • 本章聚焦验证器式评测:实现了一个数学验证器,用于抽取、归一化并借助 SymPy 校验答案。
  • 完整验证流水线包含若干核心步骤:从加载 LLM 到在数据集上运行评估。
  • 在流水线中,答案抽取通过字符串解析定位 \boxed{...} 的内容(并针对缺失盒装答案提供回退机制)。
  • 归一化步骤将多样的答案格式标准化:剥离 LaTeX,并把数学记号转换为统一形式。
  • 最终通过数学等价性检查(借助 SymPy 的符号比较)来判定两式是否相同。
  • MATH-500 数据集提供了 500 道精心挑选的数学题用于评测。
  • 提示词模板对模型表现影响显著。
  • 推理型模型的准确率普遍高于基础模型,但代价是显著更长的运行时间。