跳到主要内容

Scorer 与 Metric:打分、按 epoch 归并、聚合成指标

30 秒导读: 求解侧(见 02-solver-taskstate)跑完后,每条样本手里握着一个模型 output。评判侧要回答:这条对不对?它是一条两级归并的流水线——先把「每条样本、每个 epoch」的输出判成一个 Score,再按 sample_id 把同一样本的多个 epoch 归并成一条,最后跨所有样本聚合成 accuracy=0.82stderr=0.03 这种能写进日志、能对比的指标数字

本章只讲评判侧。样本怎么跑出 output01-eval-loop;打完的分怎么落进日志、怎么在 transcript 里回放归 06-log-transcript-sandbox


1. 这是什么(零基础也能懂)

一句话定义: 评判侧 = 把「模型说了什么」变成「一个能统计的数字」的那套东西。

解决什么问题: 假设你在评一个数学题库,跑了 500 道题、每道重复答 4 遍(4 个 epoch)。你现在有 2000 段模型输出。你想知道的其实就一个数:正确率是多少、误差有多大。从 2000 段自然语言到「82% ± 3%」这两个数字,中间要跨三道坎:

谁来跨干的事
一段输出对不对?Scorer(打分器)把一条样本一个 epoch 的 output 判成一个 Score
同一题答了 4 遍,算哪次?ScoreReducer(epoch 归并器)sample_id 把 4 个 Score 归并成 1 个
500 题合起来是多少?Metric(指标)跨样本把一堆 Score 聚合成一个数

用起来什么样: 定义一个任务时,你把打分器和它要算的指标一起声明——指标是「挂在打分器上」的:

# 示意,非源码:一个最小任务的评判侧声明
from inspect_ai import Task, task
from inspect_ai.scorer import match, accuracy, stderr

@task
def my_task():
return Task(
dataset=[...],
solver=[...],
scorer=match(location="end"), # 打分器:看输出结尾是否等于 target
)
# match 内部已经声明了 metrics=[accuracy(), stderr()],
# 所以跑完自动得到 accuracy 和 stderr 两个数字。

一句话直觉: 把它当成一条打分—求平均的装配线。Scorer 是流水线最前端的质检员(逐件判合格/不合格),Reducer 是把「同一件产品的多次质检」并成一个结论,Metric 是最后把全批次的合格率算出来贴到质检报告上。


2. 顶层全景(评判侧怎么转)

2.1 一张流水线图

先说怎么读这张图:从上到下是数据流,左边标注每一步手里数据的「粒度」(一条是什么)。

粒度 评判侧流水线 在哪
─────────────────────────────────────────────────────────────────────
每样本×每epoch ┌─ TaskState + Target ─┐
的 output │ (求解侧交付) │
└──────────┬───────────┘

① Scorer.__call__ scorer/_scorer.py:Scorer
判成一个 Score

每样本×每epoch [ Score ] value/answer/explanation
一个 Score │ scorer/_metric.py:Score

② ScoreReducer scorer/_reducer/reducer.py
按 sample_id 归并 (mean / max / pass_at_k ...)

每样本一个 Score [ SampleScore ] scorer/_metric.py:SampleScore
(跨 epoch 已合) │

③ Metric scorer/_metric.py:MetricProtocol
跨样本聚合 (accuracy / stderr / grouped ...)

整个 scorer 一组 [ EvalMetric ... ] _eval/task/results.py
最终指标 │ 装进 EvalScore → EvalResults

写进日志

2.2 部件一句话职责

部件干什么在哪个文件
Score一次打分的结果:值 + 抽取的答案 + 解释 + 元数据scorer/_metric.py:83
SampleScoreScore 套上 sample_id / 样本元数据 / 打分器名scorer/_metric.py:165
Target被打分对象要比对的「标准答案」scorer/_target.py:4
Scorer协议:(state, target) -> Score,评判的核心接口scorer/_scorer.py:34
value_to_floatScore.value(可能是 "C"/"I"/文字)翻成 floatscorer/_metric.py:199
ScoreReducer把同一样本多 epoch 的 list[Score] 归并成一个 Scorescorer/_reducer/types.py:7
Metric协议:list[SampleScore] -> Value,跨样本聚合scorer/_metric.py:259
eval_results总装:调 reducer、调 metric、拼成 EvalResults_eval/task/results.py:88

2.3 主线走一遍(高层)

  1. 求解侧把每条样本、每个 epoch 的 TaskState(含 output)和 Target 交给 Scorer。
  2. Scorer 判出一个 Score(值 + 答案 + 解释)。
  3. reduce_scoressample_id 分组,对每组用配置的 ScoreReducer 归并成一条(_eval/task/results.py:659)。
  4. 归并后的每条 SampleScore 交给每个 Metric,聚合成 accuracystderr 等数字。
  5. 数字装进 EvalScore.metrics,连同 reducer 名、样本计数一起进日志。

3. 核心原理(逐个机制,由浅入深)

3.1 打分的数据模型:Score、Value 与 value_to_float

要解决的小问题: 打分器五花八门——有的答「对/错」,有的给 0.87 的连续分,有的一次返回一组子分。得有一个统一的容器装下这些,还得有办法把它们都变成能求平均的数。

Score 装什么。 Score 是个 pydantic 模型,四个可读字段 + 一段编辑历史:

字段意思
value分值本身,类型是 Value(见下)
answer从模型输出里抽出来的那段答案(可选)
explanation为什么给这个分(可选,常存判分模型的完整回复)
metadata附加信息
history分数被人工改过的编辑轨迹

依据:scorer/_metric.py:83(Score),字段定义在 :86-99

Value 是三态的。 一个分可以是标量、可以是一列、可以是一张表——这决定了下游归并和聚合要分三条岔路走:

Value = 标量 "C" / 0.87 / True / 3
| 列表 (list) [0.8, 0.6, 1.0] —— 一条样本多个子分
| 字典 (dict) {"fluency": "C", "accuracy": "I"} —— 命名子分

依据:scorer/_metric.py:49(Value 类型别名)。Score 提供 as_float() / as_dict() / as_list() 一组便捷读法(:132-156),内部靠 _as_scalar() 强制取标量,取不到就抛错(:158)。

四个约定字母。 字符串打分器爱用四个单字母,value_to_float 把它们翻成分数:

字面量常量翻成 float
"C" correctCORRECT1.0
"I" incorrectINCORRECT0.0
"P" partialPARTIAL0.5
"N" noanswerNOANSWER0.0

依据:常量在 scorer/_metric.py:36-46value_to_float() 是个工厂,返回一个 to_float 闭包;它还顺手认 "yes"/"true"→1"no"/"false"→0、纯数字串直接转,遇到 list/dict 打警告返回 0:

# 真实实现节选,scorer/_metric.py:228 to_float
def to_float(value: Value) -> float:
if isinstance(value, int | float | bool):
return float(value)
elif value == correct:
return 1.0
elif value == partial:
return 0.5
elif value == incorrect or value == noanswer:
return 0
elif isinstance(value, str):
... # 认 yes/true/no/false 和数字串

这就是「文字判分」和「数值统计」之间的翻译层——所有 Metric 想把 Score 变成数,都要过它。

未打分的哨兵值。 有时打分器判不了(拒答、解析失败),但你仍想保留上下文。Score.unscored() 造一个 value=NaN 的分:NaN 是全套 reducer 和 metric 都认得的「跳过我」信号(scorer/_metric.py:101 unscored)。下游到处能看到 math.isnan(...) 的过滤(如 _eval/task/results.py:390)。

ScoreEdit 与编辑历史。 history 字段存的是 ScoreEdit 列表,支持事后人工改分(比如复核 UI 里把一个误判从 I 改成 C),UNCHANGED 是「这个字段不动」的哨兵。依据:scorer/_metric.py:64(ScoreEdit)、:60(UNCHANGED)。

3.2 Scorer 协议与注册:打分器怎么定义、指标怎么挂上去

要解决的小问题: 打分器要能被名字创建(从日志里恢复、从命令行指定),还要携带「我该算哪些指标」这份信息。

协议本体极简。 一个 Scorer 就是「吃 statetarget,吐一个 Score」的异步函数:

# 真实定义,scorer/_scorer.py:34 Scorer
@runtime_checkable
class Scorer(Protocol):
async def __call__(self, state: TaskState, target: Target) -> Score | None:
...

@scorer 装饰器做三件事(关键)。 它不只是注册,还把 metrics 塞进 registry 元数据:

  1. 强制打分函数是 async(不是就抛 TypeError,_scorer.py:173)。
  2. {SCORER_METRICS: metrics} 连同其它元数据打进 registry(_scorer.py:184)。
  3. 注册名字,让 scorer_create(name) 能按名重建(_scorer.py:116)。

所以 match()choice() 这些内置打分器,函数签名上都顶着 @scorer(metrics=[accuracy(), stderr()])——指标是声明在打分器上的,这就是为什么第 1 节里只写了 scorer=match() 却自动得到了两个指标。下游 scorer_metrics(scorer) 从 registry 把这份 metrics 取回来(_scorer.py:246),ScorerInfo.from_scorer 再拆出来喂给结果装配(_eval/task/results.py:60)。

Target:被比对的标准答案。 Target 就是一个字符串序列的薄包装,.text 把多个拼成一个字符串:

# 真实定义,scorer/_target.py:4 Target
class Target(Sequence[str]):
def __init__(self, target: str | list[str]) -> None:
self.target = target if isinstance(target, list) else [target]
@property
def text(self) -> str:
return "".join(self.target)

多个 target 常用于「任一命中即算对」——比如 match 逐个 target 试,命中一个就返回 CORRECT(scorer/_common.py:26)。

3.3 内置打分器:五种「怎么判对」

要解决的小问题: 从「结尾字符串相等」到「让另一个模型来判」,判对的方式差别巨大。Inspect 内置了一梯队,由简到繁:

打分器怎么判文件:符号
match / includes字符串匹配(结尾/任意/精确/包含),可选数值归一scorer/_match.py:9 / :46
pattern正则抽答案,再和 target 比scorer/_pattern.py:47
choice多选题,比对选中的字母集合scorer/_choice.py:45
model_graded_qa / _fact让判分模型给 GRADE:C/P/Iscorer/_model.py:87 / :29
math用 sympy 判数学表达式等价scorer/_math.py:683

下面挑三个有代表性的讲清「不显然的地方」。

① 字符串匹配的数值坑(match)。 天真做法 "25".endswith("5") 是 True,但 25 ≠ 5。所以 numeric=True 时走的是另一条路:抽出数字、归一化、按数值相等比,而不是字符串后缀比:

# 真实实现节选,scorer/_common.py:61 match_str
if numeric and _is_number(t):
# 目标是数:从 value 里抽数、两边归一、按数值比,
# 不再走下面的文本比较(否则 "25".endswith("5") 会误判)
v = strip_numeric_punctuation(v)
t = normalize_number(t)
...

它还会剥掉货币符号 $€£、千分位逗号(scorer/_match.py:19-30 的 docstring 明说了这套规则),但故意不剥百分号——60% 到底是 60 还是 0.6 有歧义,所以不猜。

② 模型判分与提示注入防御(model_graded_qa)。 让另一个模型当裁判:把「题目 + 提交答案 + 评分标准 + 判分指令」拼成一个 prompt,发给判分模型,再用正则从回复里抠出 GRADE: C

抠取用的默认正则藏着两个巧思:

# 真实定义,scorer/_model.py:302 DEFAULT_GRADE_PATTERN
DEFAULT_GRADE_PATTERN = (
rf"(?is).*(?<!\w)GRADE(?!\w){_GRADE_SPACING}:{_GRADE_SPACING}([CPI])"
)
  • 开头 .*贪婪的(配 DOTALL):强制匹配到最后一个 GRADE: X。指令要求裁判把结论放最后,所以思维链里早先出现的、或被提交答案「注入」进来的 GRADE 都不算数。
  • (?<!\w)GRADE(?!\w) 给 GRADE 加词边界:downgrade: 这种普通词不会被误当成判决。

更狠的是,neutralize_structural_delimiters 会把数据里的 [BEGIN DATA]/[END DATA] 改写成带连字符的形式,防止模型输出伪造这些结构标记来越狱判分 prompt(scorer/_model.py:367)。这是判分侧的安全边界,值得单独记一笔。

传一组 model 进去时,model_graded_qa 会退化成 multi_scorer(..., "mode")——多个裁判各判一次、取众数(scorer/_model.py:151)。

③ 数学等价(math)。 判「\frac{1}{2} 和 0.5 相等」不能靠字符串。math() 是一条五段流水线:预处理 unicode → 抽 \boxed{} 内容 → LaTeX 归一 → 转成 sympy 能解析的串 → 用 sympy 的 equals() 或近似相等判定(scorer/_math.py:834 check_answers)。它是整个 scorer 目录里工程量最大的一支,依赖可选的 sympy(缺了就抛 pip_dependency_error,:702)。

3.4 按 epoch 归并:ScoreReducer

要解决的小问题: 同一道题跑了 4 个 epoch,得到 4 个 Score。做统计前得先把它们并成一条——但「并」有很多种口径:平均?取最好?多数表决?4 次里至少 1 次对?

归并器就是 list[Score] -> Score 协议只有一个方法(scorer/_reducer/types.py:7)。内置一梯队,注册名即用法:

reducer归并口径符号
mean平均分(默认)reducer.py:41 mean_score
median中位数reducer.py:63 median_score
mode众数(投票)reducer.py:12 mode_score
max取最高reducer.py:206 max_score
at_least_k≥ k 次达标即算 1reducer.py:86 at_least
pass_at_kk 次里至少 1 次对的概率估计reducer.py:121 pass_at
pass_kk 次全对的概率估计reducer.py:167 pass_k

三态 Value 各归各的。 每个 reducer 内部都先看第一条有效分是标量/列表/字典,再分派到 _count_scalar / _count_list / _count_dict(或统计版 _compute_*_stat)。列表按下标逐位归并、字典按 key 逐键归并。NaN(未打分)一路被 _is_reducible 过滤掉(reducer.py:569)。

pass@k 的 NaN 陷阱(巧妙处)。 pass_at_k 有个短路:剩下的都错也不够 k 个失败时直接返回 1.0。但如果 NaN 过滤后有效 epoch 少于 k,这个短路会吐出假的 1.0。代码专门先挡了一道:

# 真实实现节选,scorer/_reducer/reducer.py:138
if total < k:
# NaN 过滤后有效 epoch 不足 k,pass@k 无定义,
# 返回 NaN 哨兵而不是短路产生的假 1.0
return float("nan")

名字里带 k 的动态注册。 pass_at/at_least 这类用 setattr(reduce, REDUCER_NAME, f"pass_at_{k}") 把 k 编进名字(reducer.py:162)。反过来,create_reducers("pass_at_5") 用正则把尾部 _5 拆成 k=5——但只在字面名不是已注册 reducer 时才拆,免得把自定义的 top_5 误拆(registry.py:148)。validate_reducer 还会在 k > epochs 时对内置 reducer 报错(registry.py:180)。

默认是 mean。 任务没配 reducer 时,结果装配用 mean_score() 兜底(_eval/task/results.py:357 _reduced_views)。

3.5 跨样本聚合:Metric

要解决的小问题: 归并后每条样本一个分,现在要把 500 条压成几个数字。

Metric 就是 list[SampleScore] -> Value 注意入参从 Score 升级成了 SampleScore——因为像分组、聚类这种指标需要读样本的元数据。旧签名 list[Score] 仍兼容,靠 is_metric_deprecated 类型嗅探区分(_eval/task/results.py:594)。

# 真实定义,scorer/_metric.py:259 MetricProtocol
@runtime_checkable
class MetricProtocol(Protocol):
def __call__(self, scores: list[SampleScore]) -> Value:
...

内置指标: 都在 scorer/_metrics/ 下,每个都是薄薄一层 numpy:

metric算什么符号
accuracy全体 to_float 后求比例_metrics/accuracy.py:15
mean全体求均值_metrics/mean.py:11
stderr均值标准误(CLT),可选聚类版_metrics/std.py:51
std / var样本标准差 / 方差_metrics/std.py:148 / :183
bootstrap_stderrbootstrap 重采样标准误_metrics/std.py:16
grouped按元数据分组各算一遍,再出「all」_metrics/grouped.py:15

accuracy 内核就三行——但注意它专门挡了空列表除零(accuracy.py:30):

# 真实实现节选,scorer/_metrics/accuracy.py:29
def metric(scores: list[SampleScore]) -> float:
if not scores:
return 0.0 # 空列表返回 0 而非除零崩溃
total = 0.0
for item in scores:
total += to_float(item.score.value)
return total / float(len(scores))

@metric 与 epoch 契约(较新、易忽略)。 装饰器有个 scores 参数,声明这个指标想吃「归并前」还是「归并后」的分:

scores=拿到的输入
"auto"(默认)归并后的分(除非显式关掉 reducer)
"reduced"明确要每样本一条(reducer 跑完)
"unreduced"每样本每 epoch 一条(每个 epoch 当独立观测,如 frequency())

依据:scorer/_metric.py:383(MetricScores)、:412 装饰器。结果装配据此把指标拆成 reduced / unreduced 两个「视图」分别算(_eval/task/results.py:195-227)。

分组指标返回字典(巧妙处)。 grouped(metric, group_key) 按样本元数据里的 key 把样本切成组,对每组跑一遍内层 metric,返回一个 {组名: 值} 字典,外加一个「all」聚合;它甚至挡了「某个组名恰好叫 all」的碰撞(grouped.py:76)。字典型返回值下游会被展开成多个 EvalMetric——这是 dict 型 Value 的用途之一。


4. 深入实现:一条分从 Score 到 EvalResults

要解决的小问题: 把上面三级串起来,看总装到底怎么拼。入口是 eval_results(_eval/task/results.py:88)。

4.1 两级归并的代码位置

eval_results() results.py:88
└─ 每个 scorer:
compute_eval_scores_for_views() results.py:173 —— 决定 reduced/unreduced 视图
├─ reduce_scores(reducer) results.py:659 ← ① 按 epoch 归并
│ 按 sample_id 分组 → reducer 归并成一条
└─ compute_eval_scores() results.py:232
└─ scorer_for_metrics() results.py:377 ← ② 跨样本调 metric
call_metric() results.py:580

① 按 epoch 归并 发生在 reduce_scores:按 str(sample_id) 分组,对每组 reducer([score...]) 归并成一条 SampleScore(results.py:659)。

② 跨样本聚合 发生在 scorer_for_metrics:先把 NaN(未打分)样本滤掉并计数 unscored_samples(results.py:388),再对每个 metric 调 call_metric

4.2 指标返回值的三种落地

scorer_for_metrics 里,metric 返回的 Value 按形状落成不同数量的 EvalMetric(results.py:418-447):

metric 返回落地
标量一个 EvalMetric
dict(如 grouped)每个 key 一个 EvalMetric
list每个下标一个 EvalMetric(名字带 -1/-2 后缀)

4.3 字典型 Score 走另一条装配路

如果打分器本身返回 dict 型 value(命名子分),且指标也按 dict 声明,则走 scorers_from_metric_dict(results.py:467):它把每个子 key 抽成标量分、对该 key 跑指标,每个 key 产出一个独立的 EvalScore——相当于「一个打分器展开成多个」。它还支持 fnmatch 通配 key(resolve_glob_metric_keys,results.py:622)。

4.4 最终产物

每个 (scorer, reducer) 组合产出一个 EvalScore,里面装着 metrics: dict[str, EvalMetric]、reducer 名、scored_samples / unscored_samples 计数(results.py:450)。所有 EvalScore 收进 EvalResults.scores。归并后的每样本分另外收进 EvalSampleReductions 供回放(results.py:203)。这些结构怎么进日志文件,见 06-log-transcript-sandbox


5. 巧妙之处(可借鉴的技术)

  • 文字/数值的翻译层集中在一处。 所有「C/I/yes/0.87」到 float 的转换只有 value_to_float 一个入口(scorer/_metric.py:199),Metric 和 Reducer 都复用它。想改判分口径,改一个工厂参数即可。
  • NaN 作为全局「未打分」哨兵。Score.unscored()(_metric.py:101)到 reducer 的 _is_reducible(reducer.py:569)到结果装配的 math.isnan 过滤(results.py:390),一条哨兵贯穿三级,不用额外的 Optional 包装。
  • 判分正则「绑最后一个 GRADE」防注入。 DEFAULT_GRADE_PATTERN 用贪婪 .* + 词边界,让思维链和被提交内容里的伪 GRADE 都失效(_model.py:302);再叠一层结构标记中和(_model.py:367)。评测框架里少见的把「裁判被越狱」当威胁模型处理。
  • pass@k 的不足样本护栏。 有效 epoch < k 时返回 NaN 而非短路假 1.0(reducer.py:138),避免 NaN 过滤悄悄抬高指标。
  • 归并前先校验形状一致。 dict/list 型分在归并前会检查所有 epoch 的 key 集合 / 长度一致,不一致直接报错而不是悄悄丢数据(reducer.py:477:510)。

6. 边界与局限

  • value_to_float 遇到 list/dict 只会打警告返回 0(_metric.py:247)。想统计复合分,得让 Metric 自己拆(如 grouped 那样),不能指望默认转换。
  • math() 依赖可选的 sympy,未安装直接抛错(_math.py:702);且它的 LaTeX 归一里有若干数据集特化的硬编码(如把 F_{30} 直接替换成斐波那契值 832040,_math.py:339),迁到别的题库要留意。
  • 模型判分的可靠性取决于裁判模型和 prompt;partial_credit 只在用默认指令时生效(自定义指令要自己写 P 的说明,_model.py:119 docstring 明示)。
  • 旧签名 Metric(list[Score])仍支持但已废弃,靠运行时类型嗅探区分(results.py:594),嗅探失败会当成 deprecated 处理。

7. 横向对比

同属评测框架,评判侧的设计取舍差异:

  • 谁定义「对」:Inspect 把 Scorer 做成可注册、可组合的一等公民,并把 metrics 声明挂在 scorer 上;许多轻量评测库把「判分」和「统计」揉在一个函数里,难以按 epoch 归并或换指标口径。
  • 多次采样的处理:Inspect 显式分「按 epoch 归并(Reducer)」和「跨样本聚合(Metric)」两级,pass@k/pass^k 这类需要多次采样的指标是 reducer 而非 metric——这让「同题多答」的统计语义清晰。

具体与本 shelf 兄弟项目的逐项对照,见总库 doc 的「评判与聚合」原理条目。


8. 代码地图(导航索引)

主题文件关键符号
打分数据模型src/inspect_ai/scorer/_metric.pyScoreSampleScoreScoreEditValuevalue_to_float
分值常量src/inspect_ai/scorer/_metric.pyCORRECTINCORRECTPARTIALNOANSWER
Scorer 协议与注册src/inspect_ai/scorer/_scorer.pyScorerscorerscorer_metricsScorerSpec
评判目标src/inspect_ai/scorer/_target.pyTarget
字符串匹配打分src/inspect_ai/scorer/_match.py_common.pymatchincludesstr_match_scorermatch_str
正则打分src/inspect_ai/scorer/_pattern.pypatternmatch_firstmatch_all_groups
多选打分src/inspect_ai/scorer/_choice.pychoice_score_target
模型判分src/inspect_ai/scorer/_model.pymodel_graded_qamodel_graded_factDEFAULT_GRADE_PATTERNneutralize_structural_delimiters
数学等价打分src/inspect_ai/scorer/_math.pymathextract_answercheck_answers
epoch 归并器src/inspect_ai/scorer/_reducer/reducer.pymean_scoremax_scoremode_scoreat_leastpass_atpass_k
reducer 注册src/inspect_ai/scorer/_reducer/registry.pyscore_reducercreate_reducersvalidate_reducer
reducer 协议src/inspect_ai/scorer/_reducer/types.pyScoreReducerScoreReducers
Metric 协议src/inspect_ai/scorer/_metric.pyMetricProtocolMetricmetricMetricScores
内置指标src/inspect_ai/scorer/_metrics/accuracymeanstderrstdvargrouped
结果装配src/inspect_ai/_eval/task/results.pyeval_resultscompute_eval_scores_for_viewsreduce_scoresscorer_for_metricscall_metric