Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

评测方法:分数变高了,究竟是哪件事变好了

你准备给一款助手换模型。旧模型得了 80 分,新模型得了 84 分,看上去只差按下发布按钮。

可再问一句,这两次做的是同一套题吗?如果不是,新题是否更容易?如果模型早已见过答案,分数又在衡量什么?就算它答得漂亮,替你取消预约时,会不会连明天那一场也一起取消?

评测的难处,往往不在怎样算平均数,而在这个平均数究竟代表什么。本专题沿着三个小实验往下走:更换题目比例、检查顺序记忆、核对任务执行后的状态。最后再看一种近期研究方向:用新文本的预测概率来评价基础模型。

建议先读模型评估与泛化、应用评估与可靠性和如何判断研究证据。本专题资料截至 2026-10-20;文末写明所读论文的版本。下面的数字来自本书自编实验,不是任何商用大模型的成绩。

1. 一个分数,背后至少有三件事

回到取消预约的助手。我们希望它理解用户意图,找到正确记录,只取消已获授权的那一场。要把这件事变成评测,需要准备任务、运行系统,再判断结果。

这三步分别决定了:

  • 考什么。 题目来自哪些用户、场景和时间,容易与困难的情况各有多少。
  • 让谁怎样做。 模型版本、提示、工具、搜索次数和执行时间怎样设定。
  • 怎样算做好了。 只看回答文字,还是核对预约状态;漏做和误改分别怎样计分。

三者中的任何一个变了,总分都可能改变。因此,“模型提高了四分”只是解释之一。也可能是题目变了、工具变强了,或者评分器更容易放行了。

如果第 ii 个任务的得分是 sis_i,一共做了 NN 个任务,普通平均分就是:

sˉ=1N∑i=1Nsi \bar{s}=\frac{1}{N}\sum_{i=1}^{N}s_i

这条公式没什么神秘之处。真正需要费心的是,每个 sis_i 为什么会是这个数,以及这些任务能代表哪些使用场景。

2. 排名翻转,未必是谁退步了

先假设有两类任务:简单题和困难题。下面是人为设定的分组正确率,用来观察加权平均,不是训练得到的成绩。

模型简单题困难题
A90%40%
B80%60%

旧题集中,80% 是简单题,20% 是困难题。模型 A 的总分为:

0.8×0.9+0.2×0.4=0.80 0.8\times0.9+0.2\times0.4=0.80

模型 B 则是 76%。A 获胜。

后来,我们希望评测更有挑战,把比例倒过来:20% 简单题,80% 困难题。两台模型在各类任务上的正确率完全没变,A 的总分却掉到 50%,B 得到 64%,排名也换了。

图 1:分组正确率保持不变,改变题目比例就能改变排名。中间的统一权重是另一个比较口径,不是自动更正确的答案。

一般地,设第 gg 类任务的平均分为 aga_g,它占评价目标的权重为 wgw_g,则:

S=∑g=1Gwgag,∑g=1Gwg=1 S=\sum_{g=1}^{G}w_ga_g, \qquad \sum_{g=1}^{G}w_g=1

GG 是类别数,权重非负。这里若让两类各占一半,A 为 65%,B 为 70%。如果实际用户大多提出简单问题,这种各占一半的分数也未必最适合产品决策。

所以,动态评测更新题目后,最好同时保留分组结果和明确的汇总规则。比较模型时,可以让它们都重做同一批新题;比较长期变化时,则还要说明难度、任务构成和权重发生了什么。把去年旧题集上的 80% 直接减去今年新题集上的 50%,得不到一个干净的“能力退步 30 个百分点”。

3. 题目够新,为什么仍要检查来源

不断换入新题,可以减少模型直接见过整套试卷的机会。LiveBench 的初始论文提供了一个具体做法:从近期材料构造任务,并尽量采用可由程序核对的答案;但这种选择也限制了它能覆盖的开放任务。这里说的是 2024 年 v1 的设计,不是对今天所有同名版本的描述。LiveBench 原文

不过,新发表的一句话,不一定包含新知识。假设今天有人说“某建筑有十二层”,而十年前的建筑资料已经写明它只有八层。一个不联网的模型若答对了,可能只是使用了旧知识。对于一般知识问答,这完全合理;如果我们想考查“寻找最新证据的能力”,这个样例就未必真的迫使系统检索。

2026 年的 Novel Claim or Déjà Vu? 正是从事实核查任务讨论这个问题。作者比较模型在不检索时写出的证据与参考证据,检查新断言是否仍可凭已有知识判断。这种基于相似度的操作性判定,不是逐条证明训练语料成员身份;其阈值和证据抽取也会影响判断。He 等原文,第 3、4 节

对我们自己的题库,先保留来源、采集时间、发生时间、修订时间和参考答案依据,会比只留一个“新题”标签更有帮助。若任务要求使用截至某天可获得的信息,答案所依据的资料也必须满足这个时间条件。

还有一条容易忽略的路径:试题虽然没有进入训练,却可能被检索系统搜到答案页。系统能联网时,要提前说明查到现成答案是否属于允许的解决方式。不同任务可以有不同规则,关键是让规则与想考查的能力一致。

4. 把试卷打乱,能看出模型见过它吗

如果一串题目曾按固定顺序出现在训练材料里,模型可能不仅记住题目,还偏爱那种排列。由此产生一个思路:比较原顺序与打乱顺序在模型下的概率。

Oren 等人的研究把这种比较写成统计检验。关键前提包括模型与待测题集在零假设下独立,以及题目顺序具有可交换性;论文还研究了分片比较以提高检测能力。它检测的是特定记忆信号,不能排除所有污染方式。Oren 等原文,第 2、3、6 节

“可交换”听起来有点抽象。可以先想象:先抽出六道题,再用独立的随机排列决定公布顺序。没有哪一种顺序天生被选中。反过来,如果题库总按年份、难度或字母排序,顺序本身就带有规律。模型偏爱它,可能有另一种解释。

这里说的是顺序的生成机制,并不是要求一个固定模型给每种排列完全相同的概率。就像公平掷骰子不要求连续六次恰好把六个点数各掷一遍,概率上的对称不能直接当作每次观察都相等。

用六个符号做一个能看清全部过程的实验

我们用 A 到 F 代表六条记录,原顺序设为 ABCDEF。它们只是记录编号,没有自然语言内容。

模型也很小:它只统计“上一个符号之后,下一个符号出现多少次”。这就是语言建模中见过的二元计数模型。设 C(a,b)C(a,b) 为训练语料中 aa 后面接 bb 的次数,我们采用加一平滑:

P(b∣a)=C(a,b)+1∑c∈VC(a,c)+∣V∣ P(b\mid a)=\frac{C(a,b)+1} {\sum_{c\in V}C(a,c)+|V|}

VV 包含 26 个字母和一个结束符,因此 ∣V∣=27|V|=27。开始符只用作条件,不在下一符号的候选中。整串记录的概率由各次条件概率相乘得到,包含最后的结束符。

基础语料包含全部 262=67626^2=676 个有序字母对,每个一次。例如 AA、AB 和 BA 都在里面。再分别建立四份训练语料:

名称在基础语料之外加入什么
基础不增加
重复原顺序50 份 ABCDEF
所有顺序六个字母的全部 720 种排列,各一份
相邻字母AB、BC,一直到 YZ,各 20 份

每份语料都实际重新统计转移次数,得到一个模型。这是计数拟合,没有神经网络训练,也没有借用任何外部模型的输出。

让所有排列都参加比较

六个不同符号共有 6!=7206!=720 种排列。这里的阶乘 6!6! 就是 6×5×4×3×2×16\times5\times4\times3\times2\times1,数量小到可以全部算一遍。

设原顺序为 xx,全部排列的集合为 Π\Pi。我们计算:

p=#{π∈Π:P(π(x))≥P(x)}720 p=\frac{\#\{\pi\in\Pi:P(\pi(x))\geq P(x)\}}{720}

#\# 表示满足条件的个数。这里把原顺序本身算进去,也把同概率的排列算进去;因此最小值是 1/7201/720,不可能为零。代码用有理数精确比较,避免浮点误差把并列拆开。

结果如下。“完整次数”专指 ABCDEF 作为一条完整训练记录出现多少次;字母和局部片段早已在基础语料中出现。最后一列统计概率不低于原序列的排列,占全部排列的多少。

训练方式完整次数排列计数
基础0720 / 720
重复原顺序501 / 720
所有顺序1720 / 720
相邻字母01 / 720

图 2:同一个低值,可能对应记住固定顺序,也可能对应有规律的原顺序。高值则可能与真实的训练重合同时存在。

5. 两个反例,比一个漂亮的检出更有用

“重复原顺序”这一组很符合直觉:训练看过很多遍后,原排列的概率独占第一。

但“所有顺序”这一组也确实看过 ABCDEF,却没有留下偏爱原顺序的信号。所有排列的概率都一样,所以 p=1p=1。没发现这种顺序记忆,不能推出模型没见过记录。

另一边,“相邻字母”模型没有读过完整的六字母记录,却偏爱从 A 到 F 的排列。原因并不神秘:训练把相邻字母的转移教得很熟,而我们又故意把字母顺序选作原顺序。这个对照违反了随机、可交换排序的前提;它不是“任何未见完整试卷的模型都应给出大 pp 值”的检验。

即使前提成立,统计检验也容许受控的误报概率。p=1/720p=1/720 不是“污染概率为 99.86%”,更不是模型作弊的判决书。它描述的是当前比较规则下,原顺序的得分有多靠前。

真实题库排列太多时,常用随机抽取的 mm 个排列比较。对应的保守写法是:

p^=1+不低于原顺序的抽样次数m+1 \hat p=\frac{1+\text{不低于原顺序的抽样次数}}{m+1}

这和上面的全部枚举不同,不能在 720 个完整排列已经包含原顺序时,再机械加一次。若只抽 99 次,最小值就是 0.01,也不能报告成十万分之一。尝试很多题库、模型或筛选规则后只展示最小值,还会改变误报风险;多重比较的问题见研究证据。

这个实验刻意保留了原始训练语料,所以我们知道哪些记录被放进去。面对训练数据未知的大模型,这种真值通常不存在。此时应把检测信号、方法假设和独立线索一起报告,而不是把一个分数写成确定的历史事实。

6. 任务完成了吗,去看发生了什么

现在把概率放到一边,回到预约助手。用户 Alice 已确认取消自己的 101 号预约。数据库还保存着 Bob 的 102 号预约,以及 Alice 的另一场 103 号预约;它们都不该被改动。

我们准备四个手写策略,每个都回答“已取消指定预约。”:

  1. 只取消用户指定的那条记录。
  2. 什么也不改,只输出这句话。
  3. 把该用户的所有预约都取消。
  4. 取消另一位用户的预约。

这不是四个语言模型,而是四段故意行为不同的程序。脚本在内存 SQLite 数据库中真正执行更新,并更换用户名和编号,完成三组任务,共 12 次执行。没有连接任何真实预约服务。

接着用三种评分办法看同一批结果。

只读回答时,12 次全部通过,因为每个策略都说了“已取消”。只查目标预约时,6 次通过:正确策略通过,取消本人全部预约的策略也通过。只有核对完整约定状态时,才能把多取消的预约也发现,最终只有正确策略的 3 次通过。

图 3:评分器看到的范围不同,对同一批执行会给出不同答案。每格分母为该策略的三个任务。

这里的完整状态评分器,从任务和初始记录独立构造期望结果,再比较最终记录。它不读取“这是正确策略还是错误策略”的标签。我们还保留了一个此前已经取消的预约,用来确认旧状态也没有被意外改变。

这类检查对应两个问题:该做的有没有做,不该改的有没有改。在真实代码修复任务中,SWE-bench 同样把补丁放回仓库执行测试;所读版本会检查相关的失败转通过测试,以及原先通过、修复后仍应通过的测试。SWE-bench,第 2 节与附录 A.4

测试通过仍有范围:没覆盖的功能可能出错,题目可能表达不清,环境也可能与实际部署不同。我们的数据库小实验只定义了这些字段和任务,没有衡量自然语言理解、网络故障恢复或过程中的信息泄露。它说明的是评分范围为什么重要,不是证明一个完整 Agent 已经可靠。

7. 开放任务,评分器也需要接受检查

预约状态可以精确比较。那“写一份解释清楚的报告”呢?它可能有许多正确写法,很难只有一个标准答案。

此时可以把事实支持、任务覆盖、格式约束和表达质量分别评价,再决定是否需要一个总分。例如,字数合规可以直接计算,引用能否支持结论需要核查来源,而论证是否清楚往往需要人的判断。让一个模型给整篇报告打一个分,虽然省事,却把几种不同问题揉在了一起。

如果引入模型评分,可以先准备一小批人工核对的对照:同样内容换顺序、正确短答与错误长答、只有引用外观却没有证据的回答。观察评分器是否把长度、位置或自信语气当成质量,再决定能在哪些场景使用它。不能只因为它来自更大的模型,就省掉这些检查。

保存原始回答和评分依据也很有用。日后发现评分规则漏了一项,可以对原回答重新计分;如果只存最终平均数,就很难知道变化来自哪里。重新评分和重新运行模型是两件不同的事,报告里应说明这次做了哪一种。

8. 不出考题,直接看模型怎样预测新文本

还有一种思路:给基础语言模型一段新文本,逐步计算它为实际词元分配的概率。预测越准确,理想情况下编码这段文本所需的比特越少。

这与从概率到学习目标中的对数损失相连。对词元序列 x1,…,xTx_1,\ldots,x_T,设模型为第 tt 个词元分配的条件概率为 ptp_t,理想码长为:

B=−∑t=1Tlog⁡2pt B=-\sum_{t=1}^{T}\log_2 p_t

BB 的单位是 bit。这里用以 2 为底的对数;若某一步正确词元的概率是 1/21/2,它贡献 1 bit,概率是 1/41/4,则贡献 2 bit。

2026 年预印本 Uncheatable Eval 用新发布的文本研究这种评价,并按原始 UTF-8 字节数归一化。论文使用的是理想码长,不包括模型参数存储和实际编码开销;作者也明确承认新文本仍可能重合,而且该指标不能代表后训练得到的指令遵循能力。Tan、Li、Shen,第 3、6 节

为什么不能直接跨分词器比困惑度

做一个更小的手算例子。文本是 ABAB,假设 A、B 独立等概率出现,长度事先知道,不额外编码结束符。

按单字母分词,四个词元的概率各为 1/21/2;按双字母分词,两次 AB 的概率各为 1/41/4。两种表示描述相同的随机来源,整段概率也相同:

(1/2)4=(1/4)2=1/16 (1/2)^4=(1/4)^2=1/16

因此理想码长都是 4 bit。但按每个词元平均的困惑度分别为 2 和 4:

PPL=2B/T \mathrm{PPL}=2^{B/T}

TT 在两种分词方式下不同,单看 2 比 4 小,不能说前者更会预测这段文字。

ABAB 的 UTF-8 长度是 4 字节,也就是 32 bit。用共同的原始字节数作分母,两种表示得到相同结果:

100×48×4=12.5% 100\times\frac{4}{8\times4}=12.5\%

注意,这不是说脚本真的产生了一个半字节的可用压缩文件。我们只算概率所对应的理想长度。换成中文时,字符数也不等于 UTF-8 字节数,应实际编码后计算长度。

这个指标测量的是给定文本上的预测表现。更会压缩一段文字,不自动意味着更会操作软件、遵守约束或使用工具。它为评测增加一个观察角度,不能替代前面的任务检查。

9. 比较之前,先把赛道写清楚

如果 A 每道题只能回答一次,B 可以搜索二十次、运行测试、挑出最好答案,那么比较的是两套系统在各自预算下的结果。这样的比较可以很有价值,但结论应把工具和预算包含进去。

你可以固定每题总时间,也可以固定调用次数或费用;这些约束不一定等价。网络等待多的工具系统,和一次很长的模型生成,可能有相同时间却不同计算量。比较时把成功率与成本一起报告,比只把胜者圈出来更有帮助。

一个便于复查的记录,可以从下面这些具体内容开始:

对象需要能找回的内容
题目版本、来源、样本 ID、筛选规则和分组权重
系统模型修订、提示、工具版本、上下文与预算
执行原始输入输出、重试、异常、时间和随机设置
评分评分代码、参考依据、逐题分数和人工判断

超时和失败也要有事先约定的处理方式。把没跑完的题直接删掉,可能让最容易失败的系统反而显得更强;环境安装失败则可以单独列出,但同样不能悄悄消失。

如果两套系统都做了相同任务,可以对逐题分差进行配对分析。若多个问题来自同一份文档,抽样时还要尊重这种关联,不能当作彼此独立。具体做法见统计与实验中的不确定性和研究证据。本专题的混合比例、全部排列和固定数据库任务没有从用户总体随机抽样,因此我们没有给这些教学结果套上一个产品成功率的置信区间。

10. 把四个小实验跑一遍

脚本只使用标准库,支持 Python 3.9 及以上版本。它会重新拟合四份计数模型、穷举排列、执行 12 次内存数据库任务,并计算混合分数与理想码长;不用下载外部数据或模型。

在仓库根目录运行:

python3 src/examples/evaluation_lab.py

也可以单独下载脚本,在它所在的目录执行 python3 evaluation_lab.py。正式实验的固定方案与全部记录也可下载。记录包含训练语料及次数、转移计数、每种排列的精确概率、数据库前后状态、环境和脚本指纹。

展开完整代码
"""评测专题:Python 3.9+,标准库;自编有限实验,不调用外部模型或服务。"""
from argparse import ArgumentParser
from collections import Counter, defaultdict
from fractions import Fraction
from hashlib import sha256
from itertools import permutations, product
from math import exp, log, log2
from pathlib import Path
import json
import platform
import sqlite3

ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
TARGET = "ABCDEF"
MODES = ("base", "repeat", "all_orders", "alphabet")
POLICIES = ("correct", "says_done", "cancel_all", "wrong_person")


def fingerprint(path):
    return sha256(Path(path).read_bytes()).hexdigest()


def plan():
    return {
        "source_sha256": fingerprint(__file__),
        "alphabet": ALPHABET, "target": TARGET, "smoothing": 1,
        "base": "each ordered pair of A..Z once, with BOS and EOS",
        "extras": {"base": "none", "repeat": "ABCDEF repeated 50 times",
                   "all_orders": "every permutation of ABCDEF once",
                   "alphabet": "AB, BC, ..., YZ each repeated 20 times"},
        "permutation": "all 720 orders; include ties and canonical order",
        "mixture": "fixed illustrative per-group rates, no fitted model",
        "environment": "three fixed tasks, four handwritten policies; in-memory SQLite",
        "compression": "ABAB; known length; binary iid probabilities, no EOS coding",
    }


def corpus(mode):
    data = Counter("".join(pair) for pair in product(ALPHABET, repeat=2))
    if mode == "repeat":
        data[TARGET] += 50
    elif mode == "all_orders":
        data.update("".join(p) for p in permutations(TARGET))
    elif mode == "alphabet":
        data.update({ALPHABET[i:i+2]: 20 for i in range(25)})
    elif mode != "base":
        raise ValueError(mode)
    return data


def fit(data):
    counts = defaultdict(Counter)
    for sequence, repeats in data.items():
        tokens = ["<BOS>"] + list(sequence) + ["<EOS>"]
        for left, right in zip(tokens, tokens[1:]):
            counts[left][right] += repeats
    return counts


def probability(counts, sequence):
    value = Fraction(1)
    tokens = ["<BOS>"] + list(sequence) + ["<EOS>"]
    # 26 个字母加 EOS;BOS 只作条件,不是下一符号候选。
    for left, right in zip(tokens, tokens[1:]):
        row = counts[left]
        value *= Fraction(row[right] + 1, sum(row.values()) + 27)
    return value


def permutation_experiment():
    result = {}
    orders = ["".join(p) for p in permutations(TARGET)]
    for mode in MODES:
        data = corpus(mode)
        counts = fit(data)
        probs = [probability(counts, s) for s in orders]
        canonical = probs[orders.index(TARGET)]
        at_least = sum(p >= canonical for p in probs)
        rows = [{"sequence": s, "numerator": p.numerator,
                 "denominator": p.denominator, "log_probability": log(p)}
                for s, p in zip(orders, probs)]
        result[mode] = {
            "training_sequences": dict(sorted(data.items())),
            "transition_counts": {s: dict(sorted(row.items()))
                                  for s, row in sorted(counts.items())},
            "training_size": sum(data.values()),
            "canonical_training_copies": data[TARGET],
            "canonical_log_probability": log(canonical),
            "at_least_canonical": at_least, "orders": len(orders),
            "p_value": at_least / len(orders), "scores": rows,
        }
    return result


def mixtures():
    rates = {"A": {"easy": .9, "hard": .4},
             "B": {"easy": .8, "hard": .6}}
    return {"group_rates": rates, "comparisons": [
        {"name": name, "easy_weight": weight,
         "scores": {m: weight*r["easy"] + (1-weight)*r["hard"]
                    for m, r in rates.items()}}
        for name, weight in [("old", .8), ("new", .2), ("equal", .5)]]}


def tasks():
    return [
        {"user": user, "target": offset+1,
         "before": [[offset+1, user, "active"],
                    [offset+2, other, "active"],
                    [offset+3, user, "active"],
                    [offset+4, user, "cancelled"]]}
        for offset, user, other in [(100, "alice", "bob"),
                                   (200, "bob", "chen"),
                                   (300, "chen", "alice")]
    ]


def act(connection, task, policy):
    # 故意保留三种错误策略,供评分器比较。所有任务均为本人已确认的取消。
    if policy == "correct":
        connection.execute("UPDATE bookings SET status='cancelled' WHERE id=? AND owner=?",
                           (task["target"], task["user"]))
    elif policy == "cancel_all":
        connection.execute("UPDATE bookings SET status='cancelled' WHERE owner=?",
                           (task["user"],))
    elif policy == "wrong_person":
        connection.execute("UPDATE bookings SET status='cancelled' WHERE id=?",
                           (task["target"]+1,))
    elif policy != "says_done":
        raise ValueError(policy)
    return "已取消指定预约。"


def grade(task, response, after):
    # 独立从任务契约构造期望状态;不读取策略名称或策略内部 SQL。
    expected = [[key, owner, "cancelled" if key == task["target"]
                 and owner == task["user"] else state]
                for key, owner, state in task["before"]]
    return {
        "text_only": "已取消" in response,
        "target_only": any(key == task["target"] and state == "cancelled"
                           for key, _, state in after),
        "full_state": sorted(after) == sorted(expected),
    }


def state_experiment():
    records = []
    for task in tasks():
        for policy in POLICIES:
            with sqlite3.connect(":memory:") as connection:
                connection.execute("CREATE TABLE bookings (id INTEGER PRIMARY KEY, owner TEXT, status TEXT)")
                connection.executemany("INSERT INTO bookings VALUES (?, ?, ?)", task["before"])
                response = act(connection, task, policy)
                after = [list(r) for r in connection.execute("SELECT * FROM bookings ORDER BY id")]
            connection.close()
            records.append({"task": task, "policy": policy, "response": response,
                            "after": after, "grades": grade(task, response, after)})
    return {"records": records, "passes": {
        key: sum(r["grades"][key] for r in records)
        for key in ("text_only", "target_only", "full_state")}}


def compression():
    text = "ABAB"
    result = []
    for units, probabilities in [(list(text), [.5]*4), (["AB", "AB"], [.25]*2)]:
        bits = -sum(log2(p) for p in probabilities)
        ppl = exp(-sum(log(p) for p in probabilities)/len(units))
        result.append({"units": units, "probabilities": probabilities,
                       "bits": bits, "perplexity": ppl,
                       "utf8_bytes": len(text.encode("utf-8")),
                       "rate_percent": 100*bits/(8*len(text.encode("utf-8")))})
    return result


def run():
    return {"source_sha256": fingerprint(__file__),
            "environment": {"python": platform.python_version(), "sqlite": sqlite3.sqlite_version},
            "permutation": permutation_experiment(), "mixtures": mixtures(),
            "states": state_experiment(), "compression": compression()}


def show(record):
    print("题目混合:")
    for row in record["mixtures"]["comparisons"]:
        print(f'{row["name"]}: A={row["scores"]["A"]:.1%}, B={row["scores"]["B"]:.1%}')
    print("全部 720 种排列(包含并列):")
    for mode, row in record["permutation"].items():
        print(f'{mode}: 完整原序列出现 {row["canonical_training_copies"]} 次, '
              f'p={row["at_least_canonical"]}/{row["orders"]}={row["p_value"]:.6f}')
    print("实际状态评分(12 次执行):")
    for key, count in record["states"]["passes"].items():
        print(f"{key}: {count}/12")
    for row in record["compression"]:
        print(f'{len(row["units"])} 个词元: PPL={row["perplexity"]:.1f}, '
              f'理想码长={row["bits"]:.1f} bit, 占 UTF-8={row["rate_percent"]:.1f}%')


def save(path, value):
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(value, ensure_ascii=False, indent=2)+"\n", encoding="utf-8")


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--plan", type=Path)
    parser.add_argument("--record", type=Path)
    args = parser.parse_args()
    if args.plan:
        save(args.plan, plan())
        return
    record = run()
    if args.record:
        save(args.record, record)
    show(record)


if __name__ == "__main__":
    main()

运行输出为:

题目混合:
old: A=80.0%, B=76.0%
new: A=50.0%, B=64.0%
equal: A=65.0%, B=70.0%
全部 720 种排列(包含并列):
base: 完整原序列出现 0 次, p=720/720=1.000000
repeat: 完整原序列出现 50 次, p=1/720=0.001389
all_orders: 完整原序列出现 1 次, p=720/720=1.000000
alphabet: 完整原序列出现 0 次, p=1/720=0.001389
实际状态评分(12 次执行):
text_only: 12/12
target_only: 6/12
full_state: 3/12
4 个词元: PPL=2.0, 理想码长=4.0 bit, 占 UTF-8=12.5%
2 个词元: PPL=4.0, 理想码长=4.0 bit, 占 UTF-8=12.5%

需要保存自己的运行时,可以加 --record runs/evaluation.json。如果修改实验,先用 --plan runs/evaluation-plan.json 保存新方案,再执行;这能帮助你区分预先的想法与看到结果后作出的调整。

第一次修改,可以先换掉原顺序。例如保留字母相邻的训练规律,却将待比较顺序设成另一个排列。再看看:原来那个极小的比例还在吗?改动前先猜一猜,往往比运行后只盯着数字更有收获。

11. 试着审查一份评测报告

  1. A 在简单题与困难题上的正确率分别为 90%、40%。简单题占 60% 时,总分是多少?这一个总分能恢复两项分组正确率吗?
  2. 两个模型分别在旧题集和新题集上得到 80%、84%。还缺哪些信息,才能讨论后者是否更强?
  3. 全部 720 种排列的概率都相同,本章的 pp 值是多少?若把比较改成严格大于,会发生什么?
  4. 只随机抽 99 个排列,用带修正的公式计算,可能报告 p=0p=0 吗?
  5. “所有顺序”训练确实包含原顺序,为什么没有检出偏爱?这说明哪种结论不能下?
  6. 相邻字母模型的原序列得分最高,能否据此证明它读过完整 ABCDEF?
  7. 取消本人全部预约的策略,为什么能通过“只查目标”评分?还应检查什么?
  8. 给完整状态评分器一份少掉一个无关预约的结果,它应该通过吗?修改脚本验证,再试试多插入一条预约。
  9. 同一文本按四个概率为 1/21/2 的词元、两个概率为 1/41/4 的词元表示,分别计算码长和困惑度。哪一个分母发生了变化?
  10. 一个任务允许十次重试,另一个只运行一次。怎样设计比较,才能分别回答“单次可靠性”和“给定预算下最好能做到什么”?
展开参考答案与实验提示
  1. 0.6×0.9+0.4×0.4=0.70.6\times0.9+0.4\times0.4=0.7,即 70%。一个总分不足以确定两个未知的分组成绩;许多组合都可能得到同样的加权平均。
  2. 至少要知道题目、任务构成、评分办法、系统工具与预算是否可比,以及逐题结果和不确定性。更直接的起点是让两者在同一套冻结题目上重跑。
  3. p=1p=1。严格大于会把所有并列排除,错误地产生零;本章把并列计入上尾,采用保守处理。
  4. 不能,最小值为 1/(99+1)=0.011/(99+1)=0.01。这个限制来自本次 Monte Carlo 比较次数,不是模型的固有属性。
  5. 全部排列以相同次数进入训练,没有特定顺序的优势。检测这种信号失败,不能证明训练语料与题目没有重合。
  6. 不能。语料强化相邻字母,而原顺序恰好按字母排列。它包含相关局部片段,但没有完整六字母训练记录;顺序生成前提也不成立。
  7. 目标预约确实取消了,但同一用户另一个应保留的预约也被取消。应核对未获授权修改的记录仍保持原状,并检查缺失或新增记录。
  8. 两种情况都不应通过。取 tasks()[0] 的初始状态,只修改目标记录后,分别删去最后一行、加入新行,传给 grade;full_state 应为 False。记录顺序调换则不应影响结果。
  9. 两种码长都为 4 bit;每词元困惑度分别是 2 和 4。变化的是词元数 TT,UTF-8 字节数没有变化。
  10. 单次可靠性让每个任务按相同运行口径执行一次,或者明确统计每次尝试。预算内表现则固定总预算,记录重试、选择规则、成本及最终结果,不能事后借标准答案挑最好的一次。重复调用不保证彼此独立。

进阶选做:对六字母模型保留固定评分函数,再把 720 种排列依次当作“原顺序”。检查有多少比例会被 p≤0.05p\leq0.05 标记,并把并列情况保留下来。这是在一个有限世界里直接核对随机排序下的误报比例;它不需要假定模型给所有顺序相同概率。

12. 沿着五篇原文继续读

以下列出本专题采用的版本。代码入口用于追溯作者方法,本书没有安装这些完整评测套件,也没有复现其大模型成绩。新基准发布后,应把具体题集版本与评分版本一起记录,不能仅凭名称沿用旧论文的实验条件。

  • Colin White 等,LiveBench: A Challenging, Contamination-Free LLM Benchmark。 所读 v1,2024-06-27。原文;作者代码。先看第 2 节任务设计与第 5 节局限,区分可程序验证的部分与未被评价的质量。
  • Yonatan Oren 等,Proving Test Set Contamination in Black Box Language Models。 所读 v2,2023-11-24。原文;作者代码。重点读零假设、可交换性和第 6 节局限。本章实现小型全排列对照,没有实现论文的分片检验。
  • Haorui He 等,Novel Claim or Déjà Vu? Rethinking “Contamination-Free” Dynamic Evaluation for Multimodal Automated Fact-Checking。 所读 v1,2026-07-26。原文。按第 3、4 节辨认“已有证据足够”的定义和相似度阈值;所读版本没有提供作者代码仓库入口,不以第三方实现补作官方代码。
  • Carlos E. Jimenez 等,SWE-bench: Can Language Models Resolve Real-World GitHub Issues? 所读 v3,2024-11-11;研究最初发表于 2023 年。原文;作者代码。从第 2 节读到附录 A.4,追踪问题、补丁、环境与通过条件怎样连接。
  • Kaifeng Tan、Yudong Li、Linlin Shen,Uncheatable Eval: Dynamic Compression-Based Evaluation of Language Models。 所读 v1,2026-09-23,预印本。原文;作者代码。重点核对第 3 节的字节归一化、类别汇总和第 6 节限制;标题不能作为“绝无污染”的保证。

回到最初的发布决定,我们现在可以问得更具体:这四分来自哪些任务?两套系统拿到了怎样的预算?原始执行里,成功和失败各是什么样子?能把这些问题答清楚,一个分数才真正开始有用。