跳到主要内容

[LLM 9/10] Benchmarking:没有置信区间的 accuracy 只是传闻

· 阅读需 28 分钟
Kobkrit Viriyayudhakorn
CEO, iApp Technology

从第 1 章开始,这个系列每次报告数字时都会重复同一句话: "没有置信区间的 accuracy 不是实验结果,它只是传闻", 并且承诺会在第 9 章完整解释——就是这一章。 我们一个 step 都不会训练,而是要从零搭出一套三种模式的评测系统, 把整个系列训练出来的每一个 checkpoint 都拿到真实的泰语考题上测一遍, 然后证明那些从来没人写进 paper 的设置,能让同一个模型的分数移动得比 leaderboard 上大家争来争去的差距还要大

Open in Colab09_benchmarking.ipynb

1. 问题(Problem statement)

来读一句每周都能刷到的话:"模型 X 在 ThaiExam 上拿到 71.2%,超过了 69.8% 的模型 Y。"

唯一该问的问题是一共测了多少道题。如果测试集只有 100 道题, 每个数字的 95% 置信区间宽度大约是 ±8–10 个点, 这意味着 71.2% 和 69.8% 是同一个数字,只是随机抽样恰好抽出了不一样的结果。 拿 100 道题上 1.4 个点的差距去宣布胜者,和抛十次硬币就断定硬币不均匀没有区别。

问题还不止于测试集的大小,因为"benchmark 分数"这一个数字, 其实是由好几层几乎没人报告的决策共同产生的:

藏起来的决策对分数的影响
用 log-likelihood 还是 generative 打分可以差好几个点
要不要对选项长度做 normalize(γ\gamma足以让 leaderboard 的名次对调
0-shot 还是 5-shot、template 怎么写可以差好几个点
给哪些模型套 chat template系统性地偏袒其中某一个模型
考题有没有泄漏进训练数据(contamination)整根柱子都是虚高的分数

一个掩盖了五层决策的数字,再加上没人画的 error bar—— 这就是我们平时所说的 leaderboard。

2. 我们要做什么(Solution)

这一章完全不训练,而这是优点,不是缺点—— 评测和训练是两种不同性质的工作,它值得拥有属于自己的一章。

我们要做四件事:

  1. 从零写出三种模式的打分系统——log-likelihood 多选题、generative exact-match (带泰语 normalization),以及配有白纸黑字 rubric 的 LLM-as-judge, 然后让你看到这三种模式给同一个模型打出的分数并不相同
  2. 把第 1–8 章的每一个 checkpoint 放到同一根标尺上测,用真实的泰语考题 (scb10x/thai_exam)、泰语数学题(VISAI-AI/gsm8k-thai)以及本系列一直在用的 KobEval-TH。
  3. 给每一个数字都配上 Wilson 95% CI,再看看这个系列的哪些结论能从 error bar 里活下来。
  4. 尝试复现公开 leaderboard 上的数字——Qwen3-0.6B 在 ThaiExam 上的成绩, 如果对不上,我们会去把原因一个个查出来,而不是默默略过。(ThaiExam 是泰语考试基准,详见 6.1。)
本章的核心观点

benchmark 分数不是模型的属性,它是 (模型 × 评测方法 × 题目集合 × 题目数量) 的属性。 只报告分数而不报告另外三样东西,等于只报告了四分之一的真相。 而置信区间,是"实验结果"这四个字的最低价码。

3. 公式(Equation)

3.1 Wilson score interval——本系列的专用 CI

如果在 nn 道题里答对了 ss 道,得到 p^=s/n\hat p = s/n,那么置信水平 zz(95% → z=1.96z = 1.96)下的 Wilson 区间是

p^+z22n1+z2n  ±  z1+z2np^(1p^)n+z24n2\frac{\hat p + \frac{z^2}{2n}}{1 + \frac{z^2}{n}} \;\pm\; \frac{z}{1 + \frac{z^2}{n}}\sqrt{\frac{\hat p(1-\hat p)}{n} + \frac{z^2}{4n^2}}

为什么不用更好记的正态近似公式(p^±zp^(1p^)/n\hat p \pm z\sqrt{\hat p(1-\hat p)/n})? 因为它恰恰在我们最需要用到它的地方崩掉:接近 0 或 1 的边界,以及 nn 很小的时候。

来看第 8 章的一个真实情形:guardrail 在 30 道测试题上一次危险请求都没有放过去。

from kobeval import wilson_ci      # 与第 1 章开始一直使用的是同一个函数

wilson_ci(0, 30) # → (0.000, 0.114)
  • 正态近似: 0±1.9601/30=0±00 \pm 1.96\sqrt{0 \cdot 1/30} = 0 \pm 0 ——区间宽度是, 好像我们只看了 30 个样本就 100% 确信漏放率就是 0%。
  • Wilson: [0%, 11.4%][0\%,\ 11.4\%] ——"看了 30 次都没漏,但真实值有可能高到 11%"。

第二个区间是诚实的说法,第一个区间是公式自动生产出来的谎言。 在公式里可以看到,Wilson 用 z2/2nz^2/2n 这一项把中心点往 1/21/2 拉(相当于补进 z24z^2 \approx 4 道虚拟题目,一半对一半错), 又用 z2/4n2z^2/4n^2 这一项防止宽度塌缩成零——这就是 kobeval 从头到尾都用 Wilson 的原因。

3.2 Length-normalised multiple-choice scoring——悄悄决定 leaderboard 的那个数字

log-likelihood 模式让模型读题目 qq,然后比较各个选项 c1,,cmc_1,\dots,c_m 整句话的概率:

i^=argmaxilogpθ(ciq)ciγ\hat i = \arg\max_i \frac{\log p_\theta(c_i \mid q)}{|c_i|^{\gamma}}
  • γ=0\gamma = 0 → 直接用原始的总 log-prob,这偏向更短的选项(token 少 = 被乘上概率的次数少)
  • γ=1\gamma = 1 → 除以 token 数,也就是使用每 token 平均 log-prob

这两行就是 lm-evaluation-harness 里的 accacc_norm,而在真实的 benchmark 上, 它们给出的分数并不相同,有时甚至会让模型的名次对调—— 当你看到两篇 paper 报告的 ThaiExam 数字不一致时,头号原因就是 γ\gamma 取值不同, 而两篇都根本没写自己用的是哪一个值。

3.3 无偏的 pass@k——用于可以自动判分的题目

数学题和代码题允许我们采样多个答案,然后问一句"里面有没有对的"。 采样 nn 次、对了 cc 次,pass@kk 的正确估计量是

pass@k^=1(nck)/(nk)\widehat{\text{pass@}k} = 1 - \binom{n-c}{k}\Big/\binom{n}{k}

凭直觉写出来的估计量 1(1c/n)k1 - (1 - c/n)^k有偏的: 函数 f(p)=1(1p)kf(p) = 1-(1-p)^k 关于 pp 是凹函数(concave), 因此根据 Jensen 不等式 E[f(p^)]f(p)\mathbb{E}[f(\hat p)] \le f(p) —— 把估计值塞进一个非线性函数里,期望值就不会等于真值了。 而上面那个二项式公式表示的是"从 nn 个里面抽 kk 个、整组全错的比例",可以证明它恰好是无偏的。

3.4 McNemar's test——在同一套题上比较两个模型

模型 A 和 B 做同一套题,令 bb = A 对但 B 错的题数,cc = B 对但 A 错的题数:

χ2=(bc1)2b+c\chi^2 = \frac{(|b-c|-1)^2}{b+c}

拿它去和 χ12\chi^2_1 比较(带 continuity correction)。关键在于 paired 这个词: 两个都答对的题和两个都答错的题根本没有出现在公式里,因为它们说明不了谁更强。 如果你用 t-test 去比较两个 accuracy,好像它们来自两套不同的题目, 那你就是把"同一道题"这个结构给扔掉了,然后要达到同样的 power 就得多用好几倍的题目。

3.5 Contamination check——考题有没有泄漏进训练数据

对于考题 xx 和训练语料 D\mathcal{D},定义 kk-gram 的重合率:

overlapk(x)=Gk(x)Gk(D)Gk(x)\text{overlap}_k(x) = \frac{\left|G_k(x) \cap G_k(\mathcal{D})\right|}{\left|G_k(x)\right|}

其中 Gk()G_k(\cdot) 是全部 kk-gram 的集合。对泰语我们使用字符级 k-gram(例如 20 个字符), 因为泰语的分词本身就有歧义。如果 overlapk\text{overlap}_k 很高(例如超过 0.7), 就要怀疑模型已经"见过答案"了——这道题上的分数衡量的是记忆,不是能力。

4. 把公式画出来(Visualize)

CI 的宽度是 n 的函数——而 n=100 给出 ±10 个点

Wilson 95% confidence interval 的半宽与题目数量的关系曲线,横轴为 10 到 10,000 道题的对数坐标,分别对应 accuracy 0.60、0.75 和 0.90,并在 n=100 处标出重点Wilson 95% confidence interval 的半宽与题目数量的关系曲线,横轴为 10 到 10,000 道题的对数坐标,分别对应 accuracy 0.60、0.75 和 0.90,并在 n=100 处标出重点

Figure 9.1Wilson 95% CI 的半宽随题目数量 n 的变化——在 n=100 时区间宽度为 ±6 到 ±9.4 个点,取决于 accuracy 的水平;而想把它收窄 10 倍,代价是题目数量增加 100 倍

这是全章最重要的一张图。宽度按 1/n1/\sqrt{n} 收缩, 所以 100 道题的测试集不可能分辨出相差 4 个点的两个模型,无论你重跑多少遍。 而如果你想有把握地读出 1 个点的差距,你需要大约一万道题——大多数泰语 benchmark 并没有这么多。

把 error bar 真的画上去之后,leaderboard 长什么样

五个模型按 accuracy 排序的 dot plot,带 Wilson 95% CI 误差棒,中间三个模型的区间互相重叠并被标注为无法区分五个模型按 accuracy 排序的 dot plot,带 Wilson 95% CI 误差棒,中间三个模型的区间互相重叠并被标注为无法区分

Figure 9.25 个模型、n=100 道题的假想排行榜——中间三名的 Wilson 区间彼此完全重叠,因此它们是「一团」,而不是三个名次(数字为便于说明而假设,CI 区间是真实计算的——真正的实测版本在第 8 节)

模型 B、C、D 之间最多相差 5 个点,但 CI 宽度有 ±9 个点——数据能支持的唯一结论是 "这三个分不出来"。谁要是宣布 D 赢了 B,那是在从噪声里读信号。

没人报告的东西,比大家争论的东西还大

同一个模型在五种 prompt template 上得分的 box plot,与两个相差两个点的模型报告分数点作对比,显示 template 带来的离散度大于模型之间的差距同一个模型在五种 prompt template 上得分的 box plot,与两个相差两个点的模型报告分数点作对比,显示 template 带来的离散度大于模型之间的差距

Figure 9.3同一个模型用 5 种语义相同的 prompt template 测出的结果——8.5 个点的离散度,比 leaderboard 上两个「竞争对手」之间 2 个点的差距还大(取值为假设值,处于 prompt sensitivity 研究中实际观察到的量级——图中已注明)

McNemar:100 道题,真正有信息量的只有 10 道

2 乘 2 表格的 heat map,显示 a=70 两者都对、b=9 只有模型 A 对、c=1 只有模型 B 对、d=20 两者都错,并附 McNemar 的卡方值与 p-value2 乘 2 表格的 heat map,显示 a=70 两者都对、b=9 只有模型 A 对、c=1 只有模型 B 对、d=20 两者都错,并附 McNemar 的卡方值与 p-value

Figure 9.4两个模型在同一套 100 道题上的 2×2 contingency 表——整个检验只用到 b 和 c 两格,χ² = 4.90、p ≈ 0.027 由真实公式算出

两个模型相差 8 个点(79% 对 71%)——听起来很明显,但真正的证据只有那 10 道意见不一致的题。 McNemar 说它刚刚才勉强越过 p=0.05p = 0.05 这条线。如果换成 b=6,c=4b=6, c=4(相差 2 个点,也就是常见 leaderboard 的量级), 得到的是 χ2=0.1\chi^2 = 0.1 ——一丁点显著性都没有。

亲手玩一玩 n → CI 的关系

这个 widget 和第 8 章(guardrails)用的是同一个,但这次请换个角度看: 里面每一个 TPR / FPR / precision 数字,都带着和公式 3.1 完全相同的 Wilson CI。 试着把 threshold τ\tau 拉到极端,直到混淆矩阵里某一格只剩下寥寥几个样本, 然后看着 CI 在你眼前撑开——这就是图 9.1 的可交互版本。

Flag a request as unsafe when score ≥ τ.
How much worse is letting an unsafe request through than blocking a safe one?
The evaluation set is 34.3% unsafe. Real traffic is usually far cleaner.
Optimal τ0.595

safeunsafeDrag the line, or use the τ slider.

ROCAUC = 0.987
00.5100.51FPRTPR
Precision-Recallat the eval base rate 34.3%
00.5100.51recallprecision
Expected costC(τ) = 10·π·FNR + (1−π)·FPR
00.5100.51τcost
Measured on the held-out set at τ = 0.500 (n = 700)
predicted unsafepredicted safe
actually unsafe220TP20FN
actually safe22FP438TN
Recall (TPR)91.7%95% CI 87.5%–94.5%
FPR4.8%95% CI 3.2%–7.1%
Precision on eval set90.9%95% CI 86.6%–93.9%
Precision at live base rate16.2%95% CI 11.0%–23.1%
Expected cost0.0557minimised at τ = 0.595
Flagged per 10,000565473 of them false alarms

Precision has collapsed.The classifier looks excellent on the evaluation set — 90.9% precision — but at a live base rate of 1.0% it drops to 16.2%. Nothing about the model changed; TPR and FPR are identical. There are simply so many more safe requests than unsafe ones that an FPR of 4.8% produces more false alarms than the classifier finds true positives. This is why a guardrail benchmarked on a balanced set falls apart in production, and why FPR, not accuracy, is the number to negotiate over.

Showing a synthetic held-out set.

5. 准备环境(Environment)

打开 Colab,选择 Runtime → Change runtime type → T4 GPU(免费额度够用)。 这一章是纯 inference——没有 optimizer,也没有 gradient——把所有 0.6B 规模的 checkpoint 扫一遍, 总共大约 15 分钟,比之前每一章都轻松。

本系列每章都要重读一遍的警告

Colab 的 T4 是 Turing 架构(SM 7.5),它不支持 bfloat16,也不支持 FlashAttention-2

但 Qwen3-0.6B 的 config.json 里写着 torch_dtype: bfloat16, 所以 torch_dtype="auto" 是个陷阱:代码会崩掉或者慢得离谱,而且不会告诉你原因。

torch_dtype=torch.float16      # 不是 bfloat16
attn_implementation="sdpa" # 不是 flash_attention_2

这一章不训练,所以不用操心 TrainingArguments 里的 fp16=True——把模型按 fp16 加载好就可以直接开测。

cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True(把 emulation 也算上了!)
is_bf16_supported() 在 T4 上会骗你

较新的 torch 在 T4 上返回 True,因为它把**模拟(emulation)**也算作支持——而模拟比 fp16 慢得多。 请改为判断 compute capability ≥ 8.0(Ampere 及以上)。这是真正在 Colab 上跑才发现的 bug。

整个系列的 checkpoint 都是从同一个 Qwen3-0.6B 底座延伸出来的,大部分是 LoRA adapter, 所以我们只加载一次底座权重,然后把 adapter 一个个挂上去再卸下来——显存不会随 checkpoint 数量膨胀:

CHECKPOINTS = {
"base": None, # 未经改动的 Qwen3-0.6B-Base
"01-cpt": "kobkrit/qwen3-0.6b-th-cpt", # full weights(第 1 章)
"02-sft": "kobkrit/qwen3-0.6b-th-sft-lora", # LoRA(第 2 章)
"03-ppo": "kobkrit/qwen3-0.6b-th-ppo-lora", # LoRA(第 3 章)
"04-dpo": "kobkrit/qwen3-0.6b-th-dpo-lora", # LoRA(第 4 章)
"05-grpo": "kobkrit/qwen3-0.6b-th-grpo-lora", # LoRA(第 5 章)
"06-ctx-dist": "kobkrit/qwen3-0.6b-th-ctxdist-lora", # LoRA(第 6 章)
"07-distill": "kobkrit/qwen3-0.6b-th-distill", # 第 7 章的学生模型
"08-guarded": "kobkrit/qwen3-0.6b-th-sft-lora+guard", # 模型 + 第 8 章的过滤器
}

6. 准备数据(Data)

6.1 scb10x/thai_exam——真正的泰国标准化考题

来自泰国真实考场(O-NET、IC、TGAT、TPAT-1、A-Level)的选择题集合。 这是公开 leaderboard 用得最多的泰语 benchmark,也是这一章的主轴。

使用前先查 dataset card 上的 license——notebook 会强制你走完这一步

真实考题是有归属方的,从真题衍生出来的数据集因此带有使用条件,你必须自己去读 Hugging Face 上的 dataset card,然后再决定是否下载——不要猜,也不要复制代码跳过这一步。 notebook 会把 card 的 metadata 拉出来打印,并停下来等你确认后才继续:

from huggingface_hub import DatasetCard

card = DatasetCard.load("scb10x/thai_exam")
print("license:", card.data.get("license"))
print(card.text[:1500]) # 用自己的眼睛读一遍 card 页面上的条款

如果 license 不允许你的使用场景(例如商业用途),就到此为止。 从违反数据使用条件开始的评测,无论如何都称不上 rigorous。

6.2 VISAI-AI/gsm8k-thai——generative 形式的数学题

GSM8K 的泰语翻译版:需要自己生成答案的数学应用题,没有选项可以用来比较 log-likelihood, 所以它正是 generative exact-match 模式的专属试验场(同样要查 card 上的 license)。

6.3 KobEval-TH——本系列固定的 100 道题

从第 1 章一直用到现在的评测集(TH-KNOW 泰国知识、指令遵循、th_ratio)。 它的优点不在于大——100 道题按图 9.1 给出的是 ±10 个点的 CI —— 而在于恒定:每一章都用同一套题、同一种方法测,所以跨章节的比较才真正成立。

6.4 泰语 normalization——泰语 exact-match 最常翻车的一关

"๕๐"、"50"、" 50 " 是同一个答案,但 Python 的 == 可不这么认为:

import re

THAI_DIGITS = str.maketrans("๐๑๒๓๔๕๖๗๘๙", "0123456789")

def normalize_thai(s: str) -> str:
s = s.strip().translate(THAI_DIGITS) # 泰文数字 ๐-๙ → 阿拉伯数字
s = s.replace(",", "") # 1,000 → 1000
s = re.sub(r"\s+", "", s) # 泰语不用空格分词 —— 全部去掉
return s

notebook 为这个函数写了小小的 unit test,因为判分器里的 bug 就是反过来的 contamination: 它会系统性地让模型看起来比真实水平更差,而且不会有任何 error 提醒你。

7. 核心代码(Main code)

7.1 模式一——log-likelihood 多选题(公式 3.2 的直接翻译)

import torch

@torch.no_grad()
def score_mc(model, tok, question, choices, gamma=1.0):
scores = []
for c in choices:
q_ids = tok(question, return_tensors="pt").input_ids.cuda()
full_ids = tok(question + c, return_tensors="pt").input_ids.cuda()
logits = model(full_ids).logits[:, :-1]
targets = full_ids[:, 1:]
logp = torch.log_softmax(logits.float(), -1).gather(
-1, targets.unsqueeze(-1)).squeeze(-1)
ans = logp[0, q_ids.shape[1] - 1:] # 只取选项部分的 token
scores.append(ans.sum().item() / len(ans) ** gamma)
return int(torch.tensor(scores).argmax()) # 全程一个 token 都不用 generate

优点:100% deterministic、快、而且对还不会好好说话的 base model 同样适用。 局限:只能测选择题,而且分数会随 γ\gamma 变化,第 9 节会看到。

7.2 模式二——generative exact-match

@torch.no_grad()
def score_generative(model, tok, question, gold):
msgs = [{"role": "user", "content": question}]
ids = tok.apply_chat_template(msgs, add_generation_prompt=True,
enable_thinking=False, # 本系列的评测约定
return_tensors="pt").cuda()
out = model.generate(ids, max_new_tokens=256, do_sample=False) # greedy
pred = tok.decode(out[0, ids.shape[1]:], skip_special_tokens=True)
return int(normalize_thai(extract_answer(pred)) == normalize_thai(gold))

extract_answer 负责从文本里把最终答案抠出来(GSM8K-TH 取最后一个数字, 选择题取选项字母)——这个函数本身也是一项会影响分数的决策,必须一并报告。

7.3 模式三——带白纸黑字 rubric 的 LLM-as-judge

JUDGE_RUBRIC = """คุณคือกรรมการตรวจข้อสอบ ตัดสินตามเกณฑ์นี้เท่านั้น:
1 = ใจความถูกต้องตรงกับเฉลย (ยอมรับการสะกดต่างกัน เลขไทย/อารบิก
และการเรียบเรียงคนละแบบที่ความหมายเดียวกัน)
0 = ผิด ตอบไม่ตรงคำถาม หรือไม่ตอบ
ห้ามให้คะแนนความสวยงามของภาษา ห้ามให้คะแนนความยาว
ตอบเป็น JSON เท่านั้น: {"score": 0 หรือ 1, "reason": "สั้น ๆ"}

โจทย์: {q}
เฉลย: {gold}
คำตอบของโมเดล: {pred}"""

这段 rubric 保持泰语原文——它是真正发给评委模型的操作性提示词, 而被评的答案也是泰语;换成中文会连带改变评委的判分行为。它的中文大意是:

你是阅卷评委,只按以下标准判分: 1 = 要点与标准答案一致(允许拼写差异、泰文数字/阿拉伯数字混用,以及意思相同但组织方式不同的表述) 0 = 错误、答非所问,或者没有作答 不得为语言的优美程度打分,不得为长度打分。只输出 JSON。

评委必须是比被评者强很多的模型。notebook 的设计允许你接入任何一个 OpenAI-compatible 的 endpoint,并且始终把 rubric 和评委的型号名写进 results.json—— 一份不说明评委是谁、用了什么标准的 judge 结果,也不过是另一种传闻。

7.4 专业人士真正在用的东西——lm-evaluation-harness

上面三种模式我们自己写,是为了看清内部结构,但真实工作应该站在被 community 检验过的工具之上:

pip install lm-eval

lm_eval --tasks list | grep -i thai # 先看看你这个版本里到底有哪些 task 名字

lm_eval --model hf \
--model_args pretrained=Qwen/Qwen3-0.6B,dtype=float16 \
--tasks thai_exam \
--num_fewshot 5 \
--batch_size 8 \
--seed 42 \
--log_samples --output_path results/harness

整条命令里最重要的是 --log_samples:它会把每一道题的回答都记录下来, 让我们可以(1)自己计算 Wilson CI、(2)逐题配对做 McNemar、(3)回过头查哪一道错在哪里。 注意 harness 会同时报告 accacc_norm——那正是公式 3.2 中的 γ=0\gamma=0 与 length-normalised 两种取法。

8. 结果(Results)

全系列总结图——所有 checkpoint 在同一根轴上,带 error bar

notebook 的结尾就是这个系列走了 8 章才画得出来的那张图: 第 1–8 章的每一个 checkpoint 排在同一根轴上,用同一套约定测量,每一根柱子都带 Wilson 95% CI (真实数字在 notebook 的 results.json 里——所以本表中一律是 ?,直到你自己跑一遍为止):

checkpointThaiExam (95% CI)GSM8K-TH (95% CI)KobEval-TH (95% CI)th_ratio
Qwen3-0.6B-Base????
第 1 章 —— CPT????
第 2 章 —— SFT-LoRA????
第 3 章 —— PPO????
第 4 章 —— DPO????
第 5 章 —— GRPO????
第 6 章 —— Context distillation????
第 7 章 —— Model distillation????
第 8 章 —— SFT + guardrail????

跑之前应该预期什么(永远先写下假设再看结果——这是本章能教给你的最好习惯): 大部分 checkpoint 在 ThaiExam 上的 CI 会互相重叠,因为我们这点训练量和 pretraining 相比实在太小了。 有可能从 error bar 里活下来的差异是 th_ratio(第 4 章正是直接针对这一点动手的), 以及 KobEval-TH 里的指令格式遵循能力(第 2 章 SFT 的功劳)。 如果真实的图和这里说的不一样,那是件值得兴奋的事,而不是该藏起来的事。

同一个答案,三个评委,三种分数

Prompt
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆ

base

Thai 18%41 tokens
The sky appears blue because of Rayleigh scattering. ท้องฟ้า is blue เพราะ light scatter ครับ. Shorter wavelengths scatter more than longer ones.

sft

Thai 99%78 tokens
ท้องฟ้าเป็นสีฟ้าเพราะแสงอาทิตย์กระทบกับโมเลกุลของอากาศแล้วเกิดการกระเจิงแบบเรย์ลี ซึ่งแสงสีน้ำเงินที่มีความยาวคลื่นสั้นกว่าจะกระเจิงได้มากกว่าแสงสีแดง เราจึงมองเห็นท้องฟ้าเป็นสีฟ้าครับ

Showing the built-in sample.

上面这些例子是模型真实的回答,它们同一道题在三种模式下拿到的分数并不相同—— 例如回答 "๕๐ บาท"(泰文数字写法的"50 铢"),不做 normalize 的 exact-match 给 0, 做了 normalize 的给 1,judge 给 1; 又比如推理过程完全正确但最后算错了数字的回答,judge 有时会比标准答案还宽容。

诚实度任务:把 leaderboard 的数字复现出来——或者搞清楚为什么复现不了

notebook 以一项比所有 cell 加起来都更有教学价值的作业收尾: 去打开一个公开的、报告了 Qwen3-0.6B 在 ThaiExam 上成绩的 leaderboard,记下他们公布的数字, 然后试着在自己的机器上产出同一个数字。

如果对不上(而第一轮通常都对不上),不要停在"已经差不多了" ——一个变量一个变量地排查:

  1. prompt template 和他们的一致吗(图 9.3 已经说明,光这一项就绰绰有余)
  2. 打分模式——他们用的是 log-likelihood 还是 generative,是 acc 还是 acc_normγ\gamma!)
  3. shot 数量——0-shot 和 5-shot 是两个世界
  4. enable_thinking——Qwen3 有内部思考模式,开与关会同时改变分数和耗时
  5. 数据集版本和 subset——thai_exam 有五个科目,他们是怎么取平均的
  6. generate 时的 batch size——padding 不同确实会让 greedy 产出不一样的结果

一份写着 "我们测得 41.8,而 leaderboard 报告 43.5,原因是他们用了 5-shot 加 acc_norm,我们用的是 0-shot 加 acc" 的报告,比一份数字完全吻合却说不出为什么的报告更有价值—— 因为前者证明了你控制得住自己的量具,后者可能只是运气好。

9. 对比(Comparison)

前面每一章比的都是"多个模型、一种评测方法"——这一章反过来: 一个模型(第 2 章的 SFT)、多种评测方法,看看分数会因为那些平常没人写进报告的决策而摇摆多少:

设置(每行只与约定相差一处)ThaiExamGSM8K-THKobEval-TH
本系列的评测约定(loglik γ=1\gamma=1、0-shot、thinking off)??
γ=0\gamma = 0 代替 γ=1\gamma = 1??
用 generative exact-match 代替 loglik???
用 5-shot 代替 0-shot???
用 LLM-as-judge 代替 exact-match???
enable_thinking=True???

(GSM8K-TH 没有选项可供比较 log-likelihood,所以只能用 generative 模式测量——那些格子是有意留空的。)

每一行都是同一个模型、每一个字节都相同的权重。你应该看到的是分数摇摆好几个点—— 比一般 leaderboard 上模型之间的差距还大,而这正是本章所提问题的最终答案: 当不同机构报告的数字对不上时,大多数情况下没有人在说谎——只是没有人用的是同一把尺。

本系列的评测约定——前面每一章都一直在遵守

第 1–8 章的每一个数字,都是在完全相同的条件下测出来的:

  • greedy decodingdo_sample=False
  • max_new_tokens=256
  • enable_thinking=False
  • seed=42
  • KobEval-TH 固定的那 100 道题,中途从未改动过 + 每个数字都带 Wilson 95% CI

第 1 章宣布这些条件的时候,它看起来只是吹毛求疵;读到这一行,你已经知道为什么了: 如果没有这份约定,第 8 节的表格根本无法跨章比较——它会变成一张用 9 把不同尺子量出来的 9 行表。

需要提防的坑

1. Contamination——考题泄漏进了我们自己的训练数据 notebook 会按公式 3.5(20-char-gram)在 ThaiExam / KobEval-TH 的题目 与我们第 1–2 章使用的语料(thaigov-v2 和合成的 instruct 数据集)之间跑一遍检查。 需要盯紧的一点是:thaigov 是泰国政府公文,而 ThaiExam 里有关于法规、条例和政务知识的题目—— 真的存在重合的可能。如果查到 hit,就要逐题报告并把它们从结论中剔除,而不是装作没看见。 (真实的扫描结果打印在 notebook 里——hit 的数量在你跑之前都是 ?。)

2. 跨 paper 比较用了不同 template 的数字 "我们的模型在 ThaiExam 上拿到 45,那篇 paper 报告的是 43"——如果 template 不同、shot 数不同、 γ\gamma 不同,这句话没有任何意义。只有在同一个 harness、同一套 config 下测出来的数字才可以比较。

3. 给一个模型套了 chat template,另一个却没套 用 chat template 去测 base model = 白白压低它的分数 / 不套 template 去测 instruct model = 同样是压分。 第 8 节的表格里我们的 checkpoint 两种都有(CPT 是 base,其余是 chat), 所以 notebook 会把每个模型第一道题的真实 prompt 打印出来供你肉眼检查——一行代码,守住了整张表的公平。

4. Judge 偏袒自己家族(self-enhancement bias) 多项研究发现,LLM judge 对和自己同一家族风格的回答给分会偏高。 如果 judge 和被评者是亲戚,数字就会甜得不正常—— 正确的解法不是去找一个"中立"的 judge(那种东西并不存在),而是永远报告 judge 的名字, 并在结果接近时用另一个家族的 judge 复核一遍。

5. 泰语 exact-match 因为泰文数字而崩掉 模型回答 "๕๐"、标准答案写的是 "50" ——忘了 normalize_thai 就立刻判错。 而且这类 bug 的分布并不均匀:用政府公文做 CPT 的模型(公文里泰文数字用得多)会比别人被扣掉更多分, 于是变成一种专门刁难某些模型的系统性 bias——判分器必须先做到公平,才谈得上排名次。

10. 小结(Summary)

  • 没有 CI 的 accuracy 只是传闻 —— n=100 给出 ±10 个点,而 leaderboard 上大部分差距都比这个小
  • Wilson 不是奢侈品,正态近似在 p^=0\hat p = 0 处给出宽度为零的 CI ——而那正是我们最需要它的地方
  • 分数是(模型 × 评测方法 × 题目 × n)的属性,打分模式、γ\gamma、shot 数、template 造成的差异,可以大过模型之间真实的差异
  • 在同一套题上比较两个模型,要用 McNemar —— 意见一致的题目不构成证据
  • pass@k 必须用二项式估计量,plug-in 估计量因为 Jensen 而有偏
  • 相信分数之前先扫一遍 contamination,尤其是训练数据和考题来自相近领域的时候
  • 第一天就宣布评测约定,之后再也不去动它 —— 本系列的约定是:greedy、256 token、thinking off、seed 42
  • 复现别人的数字并解释得清差异,比拿到吻合的数字却不知道原因更有价值
这个实验的局限

Benchmark 衡量的是容易衡量的东西,而不是重要的东西。 选择题可以自动判分,于是它成了标准, 但真实用户不会带着 A B C D 四个选项来——他们带来的是长长的问题、各自特殊的上下文, 以及根本没法用 accuracy 衡量的期待。

ThaiExam 分数高不代表对泰国人有用。 一个 A-Level 考得很好的模型, 可能起草不好一份公文、回答客户时一点也不自然,或者僵硬到没有人愿意用它。 考试分数和真实使用价值之间的相关性,比 leaderboard 让我们感觉到的松散得多。

还有一条值得贴在墙上:在你自己产品的真实 traffic 上做 30 条 human eval, 往往比 10,000 道选择题告诉你的东西更多 —— 它的 CI 确实更宽(公式 3.1 已经告诉我们宽到什么程度), 但它粗略地衡量了正确的东西,而这永远胜过精确地衡量错误的东西。 这一章给出的统计工具对两类评测都适用——请不要只把它用在那个更方便的类别上。

下一章: Deployment —— 测量完成的模型必须走出去面对真实用户。 quantization 要拿什么去换、能部署在什么样的机器上,以及这一章得到的数字如何变成 production 的 regression test。

参考文献(References)

  1. Liang et al. (2022). Holistic Evaluation of Language Models — HELM:用多维度评测取代单一数字
  2. Biderman et al. (2024). Lessons from the Trenches on Reproducible Evaluation of Language Models — lm-evaluation-harness 关于可复现评测的经验
  3. Miller (2024). Adding Error Bars to Evals: A Statistical Approach to Language Model Evaluations — 每个 accuracy 数字都需要误差棒的理由
  4. Zheng et al. (2023). Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena — LLM-as-a-judge 及其偏见
  5. Chiang et al. (2024). Chatbot Arena: An Open Platform for Evaluating LLMs by Human Preference — 以真实人类偏好进行排名
  6. Hendrycks et al. (2020). Measuring Massive Multitask Language Understanding — MMLU:多项选择基准的范式
  7. Chen et al. (2021). Evaluating Large Language Models Trained on Code — 第 9 节所用的 pass@k 无偏估计
  8. Wilson (1927). Probable Inference, the Law of Succession, and Statistical Inference — 本系列每个数字所用的 Wilson 区间
  9. McNemar (1947). Note on the Sampling Error of the Difference Between Correlated Proportions or Percentages — 在同一批题目上比较两个模型的配对检验

本系列的文章、代码与 notebook 均以 CC BY-NC-SA 4.0 授权 —— 可自由使用与改编,须署名、限非商业用途,并以相同方式共享。文中引用的第三方模型与数据集仍适用各自的许可证。