跳转至

判分:从字符串比较到模型当裁判

baseline 用 == 判对错:模型多说一个字就算错,开放题根本没有能拿来比的答案。Evals 的判法分两层:答案短而确定的,用三种宽窄不同的字符串比较;答案开放的,把被测回答填进一段打分 prompt,交给另一个模型选一个选项,再折成分数。本篇只读了源码,没有实跑,每节只贴源码模版。

三种字符串判法:看开头、看包含、归一化后互相包含

问题:标准答案是 Millie Bobby Brown。模型答 Millie Bobby Brown plays Eleven.,== 判错;答 The actress is Millie Bobby Brown,只看开头也判错。

功能:三个字符串模板,从严到宽。项目文档1用一行式子写各自的判法,a 是回答,B 是标准答案列表:

  • basic/match.py:Match: any([a.startswith(b) for b in B])
  • basic/includes.py:Includes: any([(b in a) for b in B])
  • basic/fuzzy_match.py:FuzzyMatch: any([(a in b or b in a) for b in B])

Match 逐个标准答案看回答是不是以它开头,命中就记一条 match 事件:

模版 · evals/api.py:82-105
    picked = None
    for option in options:
        if not sampled.startswith(option):
            continue
        if (
            separator is not None
            and len(sampled) > len(option)
            and not separator(sampled[len(option)])
        ):
            continue
        picked = option
        break

    result = {
        "prompt": prompt,
        "sampled": sampled,
        "options": options,
        "picked": picked,
    }
    match = picked in expected
    result["expected"] = expected
    result["match"] = match
    record_match(match, expected=expected, picked=picked, sampled=sampled, options=options)
    return picked

Includes 在回答里从后往前找标准答案,可选忽略大小写:

模版 · evals/elsuite/utils.py:17-25
def get_answer(text, answer_prompt, ignore_case=False):
    if ignore_case:
        idx = text.lower().rfind(answer_prompt.lower())
    else:
        idx = text.rfind(answer_prompt)

    if idx == -1:
        return None
    return text[idx : idx + len(answer_prompt)]

FuzzyMatch 先归一化:转小写、删标点、删冠词 a / an / the、合并空白;再看两边谁包含谁。另外按词算 F1,取各标准答案里的最高值,作为第二个指标:

模版 · evals/elsuite/utils.py:36-53
def normalize(s: str) -> str:
    """Lower text and remove punctuation, articles and extra whitespace."""
    s = s.lower()
    exclude = set(string.punctuation)
    s = "".join(char for char in s if char not in exclude)
    s = re.sub(r"\b(a|an|the)\b", " ", s)
    s = " ".join(s.split())
    return s


def fuzzy_match(s1: str, s2: str) -> bool:
    s1 = normalize(s1)
    s2 = normalize(s2)

    if s1 == "" or s2 == "":
        return s1 == s2

    return s1 in s2 or s2 in s1
模版 · evals/elsuite/utils.py:75-88
def f1_score(prediction: str, answers: list[str]) -> float:
    def _f1_score(prediction: str, ground_truth: str):
        prediction_tokens = normalize(prediction).split()
        ground_truth_tokens = normalize(ground_truth).split()
        common = Counter(prediction_tokens) & Counter(ground_truth_tokens)
        num_same = sum(common.values())
        if num_same == 0:
            return 0
        precision = 1.0 * num_same / len(prediction_tokens)
        recall = 1.0 * num_same / len(ground_truth_tokens)
        f1 = (2 * precision * recall) / (precision + recall)
        return f1

    return max([_f1_score(prediction, answer) for answer in answers])

三种判法都只问「标准答案在不在」,不管回答多说了什么。Match 留了 separator 参数,可以要求标准答案后面紧跟分隔符,但三个模板都没传,所以标准答案 4 碰上回答 42 也算对。回答要求是 JSON 的,另有 JsonMatch:两边都解析成 JSON,键集合相同、值逐个相同才算对,解析不了就算错。

打分规格:开放题交给裁判模型

问题:题目是「write a 1-2 funny lines about apple」,没有标准答案;好不好笑,只能找个人或者找个模型来判。

功能:模型打分(领域里叫 LLM-as-a-judge,项目叫 model-graded eval)。一份打分规格(项目叫 modelgraded spec)写四样东西:打分 prompt、选项、各选项的分值、哪个输入对应哪个回答:

模版 · evals/elsuite/modelgraded/base.py:11-25
@dataclass
class ModelGradedSpec:
    # must have
    prompt: Union[str, OpenAICreateChatPrompt]
    choice_strings: Union[list[str], str]
    input_outputs: dict[str, str]

    # optional
    eval_type: Optional[str] = None
    choice_scores: Optional[Union[dict[str, float], str]] = None
    output_template: Optional[str] = None

    # unused
    key: Optional[str] = None
    group: Optional[str] = None

判笑话的规格。input_outputs: {input: completion} 的意思是:拿样本的 input 字段去问被测模型,回答填进打分 prompt 的 {completion} 空;Unsure 记半分:

模版 · evals/registry/modelgraded/humor.yaml:1-15
humor:
  prompt: |-
    Is the following funny? 

    {completion}
  choice_scores:
    "Yes": 1.0
    "No": 0.0
    "Unsure": 0.5
  choice_strings:
    - "Yes"
    - "No"
    - "Unsure"
  input_outputs:
    input: completion

判一条样本分三步:按 input_outputs 采被测回答,样本里已经有回答字段就跳过;把回答、样本字段、eval 条目给的额外参数一起填进打分 prompt,交给裁判;记下选项和分数:

模版 · evals/elsuite/modelgraded/classify.py:53-102
    def eval_sample(self, test_sample: dict, rng: Random) -> None:
        """Evaluate a single sample.

        Recorded metrics are always: one of the self.choice_strings, or "__invalid__".
        """
        # process test_sample
        for k in self.mg.input_outputs:
            test_sample[k] = scrub_formatting_from_prompt(test_sample[k])

        # run policy completions
        completions = {}
        for k, v in self.mg.input_outputs.items():
            if v in test_sample:  # test_sample already has completion, skip.
                continue
            if self.multicomp_n > 1:
                completion = sample_and_concat_n_completions(
                    self.completion_fns,
                    prompt=test_sample[k],
                    template_i=self.mg.output_template,
                    sample_kwargs=self.sample_kwargs,
                    n=self.multicomp_n,
                )
            else:
                get_input_completion = PromptFn(
                    test_sample[k], completion_fn=self.completion_fn, **self.sample_kwargs
                )
                completion, _ = get_input_completion()
            completions[v] = completion

        # run modelgraded eval
        metrics = {}
        choice, info = classify(
            mg=self.mg,
            completion_fn=self.eval_completion_fn,
            completion_kwargs=self.eval_kwargs,
            eval_type=self.eval_type,
            n=self.multicomp_n,
            match_fn=self.match_fn,
            format_kwargs={**completions, **test_sample, **self.modelgraded_spec_args},
        )
        metrics.update(dict(choice=choice, score=info["score"]))

        # run metaeval if requested
        if self.metaeval:
            assert "choice" in test_sample
            metrics["metascore"] = choice == test_sample["choice"]

        evals.record.record_metrics(**metrics)

        return choice

打分 prompt 里的空可以由 eval 条目来填。closedqa 是一份「按某条标准判 Y / N」的规格,CoQA 题库拿它配了三条标准,只改 modelgraded_spec_args:

模版 · evals/registry/evals/coqa-ex.yaml:32-49
coqa-closedqa-correct.dev.v0:
  class: evals.elsuite.modelgraded.classify:ModelBasedClassify
  args:
    samples_jsonl: coqa/samples.jsonl
    modelgraded_spec: closedqa
    modelgraded_spec_args:
      criteria: "correctness: Is the answer correct?"

coqa-closedqa-relevance:
  id: coqa-closedqa-relevance.dev.v0
  metrics: [accuracy]
coqa-closedqa-relevance.dev.v0:
  class: evals.elsuite.modelgraded.classify:ModelBasedClassify
  args:
    samples_jsonl: coqa/samples.jsonl
    modelgraded_spec: closedqa
    modelgraded_spec_args:
      criteria: "relevance: Is the submission referring to a real quote from the text?"

打分规格只管「怎么问裁判」;用哪份数据、按什么标准判,写在 eval 条目里,所以一份规格能复用到很多 eval 上。

裁判先推理,再倒着找选项

问题:裁判可能一上来先给结论,再替结论找理由;也可能写一大段,中间提到好几个选项字母,从头找就会撞上错的那个。

功能:答题格式(eval_type)有三种:只写选项(classify),先选再解释(classify_cot),先推理再选(cot_classify)。在 eval 条目里写上 eval_type,程序就往打分 prompt 末尾追加对应的说明,选项列表自动填进去:

模版 · evals/elsuite/modelgraded/classify_utils.py:13-28
ANSWER_PROMPTS = {
    # e.g. "Yes"
    "classify": "Answer the question by printing only a single choice from {choices} (without quotes or punctuation) corresponding to the correct answer with no other text.".strip(),
    # e.g. "Yes\n The reasons are: ..."
    "classify_cot": "First, answer by printing a single choice from {choices} (without quotes or punctuation) corresponding to the correct answer. Then, from the next line, explain your reasonings step by step.".strip(),
    # e.g. "Let's think step by step. ...\nYes"
    "cot_classify": """
First, write out in a step by step manner your reasoning to be sure that your conclusion is correct. Avoid simply stating the correct answer at the outset. Then print only a single choice from {choices} (without quotes or punctuation) on its own line corresponding to the correct answer. At the end, repeat just the answer by itself on a new line.

Reasoning:""".strip(),
    "cot_classify_jp": """
まず、一歩一歩あなたの推論を書き出してください。単に正しい答えを最初に述べることを避けてください。次に、{choices}(引用符や句読点なし)から正しい答えに対応する1つの選択肢を単独の行に書きだしてください。最後に、答えだけを新しい行に繰り返してください。

推論:
    """.strip(),
}
模版 · evals/elsuite/modelgraded/classify_utils.py:131-149
def append_answer_prompt(
    prompt: OpenAICreateChatPrompt,
    eval_type: str,
    append_type: str = "as_content",
    answer_prompt: Optional[OpenAICreateChatPrompt] = None,
    choice_strings: Optional[Iterable[str]] = None,
) -> OpenAICreateChatPrompt:
    """Append answer prompt to prompt."""
    answer_prompt = answer_prompt or ANSWER_PROMPTS[eval_type]
    answer_prompt = format_prompt(answer_prompt, choices=choice_to_str(choice_strings))
    if append_type == "as_content":
        assert isinstance(answer_prompt, str), f"prompt must be str, not {type(answer_prompt)}"
        prompt[-1]["content"] += "\n\n" + answer_prompt
    elif append_type == "as_message":
        assert is_chat_prompt(answer_prompt), f"prompt must be chat prompt, not {answer_prompt}"
        prompt += answer_prompt
    else:
        raise ValueError(f"append_type must be 'as_content' or 'as_message', not {append_type}")
    return prompt

解析时逐行去空白、删标点。cot_classify 把行倒过来,从最后一行往上找;默认的匹配规则是这一行以选项开头或结尾:

模版 · evals/elsuite/modelgraded/classify_utils.py:110-128
def get_choice(
    text: str, eval_type: str, match_fn: Union[str, Callable], choice_strings: Iterable[str]
) -> str:
    """Clean the answer string to a choice string to one of choice_strings. Return '__invalid__.' if no match."""
    if isinstance(match_fn, str):
        match_fn = MATCH_FNS[match_fn]
    lines = text.strip().split("\n")
    if eval_type.startswith("cot_classify"):
        lines = lines[::-1]  # reverse lines
    for line in lines:
        line = line.strip()
        line = "".join(c for c in line if c not in string.punctuation)
        if not line:
            continue
        for choice in choice_strings:
            if match_fn(line, choice):
                return choice
    logging.warn(f"Choices {choice_strings} not parsable for {eval_type}: {text}")
    return INVALID_STR
模版 · evals/elsuite/modelgraded/classify_utils.py:29-34
MATCH_FNS = {
    "include": lambda x, y: float(x in y),
    "exact": lambda x, y: float(x == y),
    "endswith": lambda x, y: x.endswith(y),
    "starts_or_endswith": lambda x, y: x.startswith(y) or x.endswith(y),
}

解析前要删标点,所以选项本身不许带标点,加载时就检查;from_n、from_n_ABC 这几个写法按回答份数生成 1..n 或 A..:

模版 · evals/elsuite/modelgraded/classify_utils.py:37-48
def get_choice_strings(choice_strings: Union[list[str], str], n: Optional[int] = None):
    # 'choice_strings' is a list of strings that specifies the possible choices
    if choice_strings == "from_n":
        choice_strings = [str(i + 1) for i in range(n)]
    elif choice_strings == "from_n_abc":
        choice_strings = [string.ascii_lowercase[i % 26] for i in range(n)]
    elif choice_strings == "from_n_ABC":
        choice_strings = [string.ascii_uppercase[i % 26] for i in range(n)]
    # make sure each choice doesn't contain any punctuation
    for s in choice_strings:
        assert not any(c in s for c in string.punctuation), f"{s} contains punctuation"
    return choice_strings

项目文档1推荐默认用 cot_classify,说它「typically provides the most accurate model-graded evaluations」。说明里还要求「最后单独一行再写一遍答案」,倒着找时碰上的第一行就是它。

解析不出:记 invalid,按最低分算

问题:裁判写了一段话却没给选项,或者给了一个规格里没有的 Maybe。

功能:找不到选项就记成 __invalid__,分数取分值表里的最低值。分值表也可以写成 from_strings,选项本身就是分数,用来做 1 到 5 的 Likert 量表:

模版 · evals/elsuite/modelgraded/classify_utils.py:90-102
def get_choice_score(
    choice: str,
    choice_strings: Iterable[str],
    choice_scores: Optional[Union[dict[str, float], str]] = None,
) -> Optional[float]:
    if choice_scores is None:
        return None
    if choice_scores == "from_strings":
        choice_scores = {c: float(c) for c in choice_strings}
    # assumption: each INVALID_STR contributes the lowest score
    if choice == INVALID_STR:
        return min(choice_scores.values())
    return choice_scores[choice]

汇总时按选项计数,invalid 也是一项;再算平均分,有校准就再算一致率:

模版 · evals/elsuite/modelgraded/classify.py:104-127
    def run(self, recorder):
        samples = self.get_samples()

        self.eval_all_samples(recorder, samples)
        record_metrics = {}

        all_sample_metrics = recorder.get_metrics()
        if not all_sample_metrics:
            return record_metrics

        # record the counts
        choices = [m["choice"] for m in all_sample_metrics]
        counts = dict(Counter(choices))
        record_metrics.update({f"counts/{k}": v for k, v in counts.items()})

        # record the scores
        scores = [m["score"] for m in all_sample_metrics if m["score"] is not None]
        if scores:
            record_metrics["score"] = sum(scores) / len(scores)
        metascores = [m["metascore"] for m in all_sample_metrics if "metascore" in m]
        if metascores:
            record_metrics["metascore"] = sum(metascores) / len(metascores)

        return record_metrics

invalid 按最低分算,等于把裁判的格式失误记在被测方头上,分数只会往低偏;counts/__invalid__ 单独列出来,裁判失灵时一眼就看得到。

一题多答:比高下、挑最好

问题:三个模型各写一个笑话,谁的最好笑?或者同一个模型写猫的笑话、写苹果的笑话,哪个写得更好?

功能:被测接口和裁判都写在命令行上,逗号分隔,最后一个当裁判,前面的都是被测方;只给一个时,它既答题又打分:

模版 · evals/elsuite/modelgraded/classify.py:28-33
        super().__init__(*args, **kwargs)
        # treat last completion_fn as eval_completion_fn
        self.eval_completion_fn = self.completion_fns[-1]
        if len(self.completion_fns) > 1:
            self.completion_fns = self.completion_fns[:-1]
        n_models = len(self.completion_fns)

best 比模型:几个被测方各答一次,按 output_template 拼成编号列表,选项是 1..n。multicomp_n: from_models 表示份数等于被测方的个数;改成一个数字,就是同一个模型采几次,diversity 用的就是这个:

模版 · evals/registry/modelgraded/best.yaml
best:
  prompt: |-
    Which of the following {n} texts is the best response to the following instruction?

    Instruction: {input}

    Responses:
    {completion}
  choice_strings: from_n
  input_outputs:
    input: completion
  output_template: "{i}. {output}\n"
模版 · evals/registry/evals/test-modelgraded.yaml:85-98
# a simple modelgraded eval checking which of N completions to the sample prompt is the best response
# command: `oaieval gpt-3.5-turbo,gpt-4,gpt-3.5-turbo best`
best:
  id: best.dev.v0
  metrics: [accuracy]
best.dev.v0:
  class: evals.elsuite.modelgraded.classify:ModelBasedClassify
  args:
    samples_jsonl: test_modelgraded/joke_fruits.jsonl
    eval_type: cot_classify
    modelgraded_spec: best
    multicomp_n: from_models
    sample_kwargs:
      temperature: 0.4
模版 · evals/elsuite/modelgraded/classify_utils.py:152-187
def sample_and_concat_n_completions(
    completion_fns: list[CompletionFn],
    prompt: OpenAICreateChatPrompt,
    n: int,
    template_i: str,
    sample_kwargs: dict,
):
    assert template_i
    completion_i_s = []
    for i in range(n):
        if len(completion_fns) > 1:
            # use a separate model for each completion
            assert len(completion_fns) == n
            completion_fn = completion_fns[i]
        else:
            # use the single model for all completions
            completion_fn = completion_fns[0]
        get_input_completion = PromptFn(prompt, completion_fn=completion_fn, **sample_kwargs)
        completion_i, _ = get_input_completion()
        completion_i_s.append(completion_i)
    return concat_n_completions(completion_i_s, template_i=template_i)


def concat_n_completions(completions: Iterable[str], template_i: str) -> str:
    """Concatenate n completions into a single text string."""
    completion = ""
    for i, completion_i in enumerate(completions):
        completion += format_necessary(
            template_i,
            i=i + 1,
            i_abc=string.ascii_lowercase[i % 26],
            i_ABC=string.ascii_uppercase[i % 26],
            output=completion_i,
            n=len(completions),
        )
    return completion.strip()

battle 比题目:一个样本里放两道题,同一个被测模型各答一份,两份回答填进同一个打分 prompt,分值记的是「第一份更好」的比例:

模版 · evals/registry/modelgraded/battle.yaml
battle:
  prompt: |-
    You are comparing two responses to the following two instructions.

    [Instruction 1]
    {input1}
    [Response 1]
    {completion1}

    [Instruction 2]
    {input2}
    [Response 2]
    {completion2}


    Is the first response better than the second? You must provide one answer based on your subjective view.
  choice_strings:
    - "Yes"
    - "No"
  choice_scores:
    "Yes": 1.0
    "No": 0.0
  input_outputs:
    input1: completion1
    input2: completion2
模版 · evals/registry/data/test_multiio/battles/joke_animals_vs_fruits.jsonl:1-2
{"input1": "write a 1-2 line joke about cat", "input2": "write a 1-2 line joke about apple"}
{"input1": "write a 1-2 line joke about cat", "input2": "write a 1-2 line joke about banana"}

谁排第一,best 由命令行的顺序决定,battle 由样本决定。裁判偏爱第一个这类位置偏差,框架不管;要控制,就把顺序对调再跑一遍。

裁判校准:同一份数据加人工标签

问题:裁判说八成笑话好笑,可它判得跟人一致吗?换一句打分 prompt、换一种答题格式,裁判是更准了还是更差了?

功能:裁判校准(项目叫 meta-eval)。数据里直接给出回答和人工标的选项 choice。有回答就不采样,所以整轮只测裁判:

模版 · evals/registry/data/test_metaeval/joke_fruits_labeled.jsonl:1-2
{"input": "write a 1-2 funny lines about apple", "completion": "Why did the apple go to the doctor? Because it had a bad core-ache!", "choice": "Yes"}
{"input": "write a 1-2 boring lines about apple", "completion": "Apples are a type of fruit that grow on trees and are often consumed as a healthy snack.", "choice": "No"}

eval 条目多写一行 metaeval: true:

模版 · evals/registry/evals/test-modelgraded.yaml:34-44
# a meta-evaluation of a modelgraded eval checking if a completion is funny or not
# this example uses a labeled dataset with "completion" and "choice"
joke-fruits-meta:
  id: joke-fruits-meta.dev.v0
  metrics: [accuracy]
joke-fruits-meta.dev.v0:
  class: evals.elsuite.modelgraded.classify:ModelBasedClassify
  args:
    samples_jsonl: test_metaeval/joke_fruits_labeled.jsonl
    eval_type: cot_classify
    modelgraded_spec: humor

每条样本比一次裁判选项和人工标签,汇总成 metascore:

模版 · evals/elsuite/modelgraded/classify.py:95-98
        # run metaeval if requested
        if self.metaeval:
            assert "choice" in test_sample
            metrics["metascore"] = choice == test_sample["choice"]

同一份标注数据,项目配了三个校准条目,只差 eval_type,用来比三种答题格式下裁判和人有多一致。项目文档2的要求:

After running the meta-eval, e.g., oaieval gpt-3.5-turbo joke-fruits-meta, the report will output metascore/ accuracies, which should be close to "1.0" for a good model-graded eval.

校准没有新代码,只是同一个模板多了一个开关,贡献者交打分 YAML 时顺手就能带上一份标注数据。

谁管什么

  • 程序:字符串判法的归一化和匹配;往打分 prompt 末尾追加答题说明;填空;逐行删标点、倒着找选项;找不到记 invalid、按最低分算;按选项计数,算平均分和一致率;多份回答拼成编号列表
  • 模型:被测模型答原题;裁判模型读打分 prompt,选一个选项——推理写什么、最后选哪个,都由它定
  • 用户:选判法;写打分 prompt、选项和分值;选答题格式;定谁当裁判(命令行最后一个);准备人工标签跑校准;要控制位置偏差,自己对调顺序再跑

附录:源码与文档

evals/elsuite/modelgraded/classify.py 全文
"""
Generic eval that uses a prompt + classification.
"""
from collections import Counter
from random import Random
from typing import Any, Optional, Union

import evals
import evals.record
from evals.elsuite.modelgraded.classify_utils import classify, sample_and_concat_n_completions
from evals.elsuite.utils import PromptFn, scrub_formatting_from_prompt


class ModelBasedClassify(evals.Eval):
    def __init__(
        self,
        modelgraded_spec: str,
        *args,
        modelgraded_spec_args: Optional[dict[str, dict[str, str]]] = None,
        sample_kwargs: Optional[dict[str, Any]] = None,
        eval_kwargs: Optional[dict[str, Any]] = None,
        multicomp_n: Union[int, str] = 1,
        eval_type: Optional[str] = None,
        match_fn: Optional[str] = None,
        metaeval: bool = False,
        **kwargs,
    ):
        super().__init__(*args, **kwargs)
        # treat last completion_fn as eval_completion_fn
        self.eval_completion_fn = self.completion_fns[-1]
        if len(self.completion_fns) > 1:
            self.completion_fns = self.completion_fns[:-1]
        n_models = len(self.completion_fns)
        self.sample_kwargs = {"max_tokens": 1024}
        self.sample_kwargs.update(sample_kwargs or {})
        self.eval_kwargs = {"max_tokens": 1024}
        self.eval_kwargs.update(eval_kwargs or {})
        self.metaeval = metaeval
        self.modelgraded_spec_args = modelgraded_spec_args or {}
        self.eval_type = eval_type
        self.match_fn = match_fn
        if multicomp_n == "from_models":
            assert n_models > 1
            self.multicomp_n = n_models
        else:
            assert isinstance(multicomp_n, int)
            self.multicomp_n = multicomp_n
        if len(self.completion_fns) > 1:
            assert self.multicomp_n == n_models

        self.mg = self.registry.get_modelgraded_spec(modelgraded_spec)

    def eval_sample(self, test_sample: dict, rng: Random) -> None:
        """Evaluate a single sample.

        Recorded metrics are always: one of the self.choice_strings, or "__invalid__".
        """
        # process test_sample
        for k in self.mg.input_outputs:
            test_sample[k] = scrub_formatting_from_prompt(test_sample[k])

        # run policy completions
        completions = {}
        for k, v in self.mg.input_outputs.items():
            if v in test_sample:  # test_sample already has completion, skip.
                continue
            if self.multicomp_n > 1:
                completion = sample_and_concat_n_completions(
                    self.completion_fns,
                    prompt=test_sample[k],
                    template_i=self.mg.output_template,
                    sample_kwargs=self.sample_kwargs,
                    n=self.multicomp_n,
                )
            else:
                get_input_completion = PromptFn(
                    test_sample[k], completion_fn=self.completion_fn, **self.sample_kwargs
                )
                completion, _ = get_input_completion()
            completions[v] = completion

        # run modelgraded eval
        metrics = {}
        choice, info = classify(
            mg=self.mg,
            completion_fn=self.eval_completion_fn,
            completion_kwargs=self.eval_kwargs,
            eval_type=self.eval_type,
            n=self.multicomp_n,
            match_fn=self.match_fn,
            format_kwargs={**completions, **test_sample, **self.modelgraded_spec_args},
        )
        metrics.update(dict(choice=choice, score=info["score"]))

        # run metaeval if requested
        if self.metaeval:
            assert "choice" in test_sample
            metrics["metascore"] = choice == test_sample["choice"]

        evals.record.record_metrics(**metrics)

        return choice

    def run(self, recorder):
        samples = self.get_samples()

        self.eval_all_samples(recorder, samples)
        record_metrics = {}

        all_sample_metrics = recorder.get_metrics()
        if not all_sample_metrics:
            return record_metrics

        # record the counts
        choices = [m["choice"] for m in all_sample_metrics]
        counts = dict(Counter(choices))
        record_metrics.update({f"counts/{k}": v for k, v in counts.items()})

        # record the scores
        scores = [m["score"] for m in all_sample_metrics if m["score"] is not None]
        if scores:
            record_metrics["score"] = sum(scores) / len(scores)
        metascores = [m["metascore"] for m in all_sample_metrics if "metascore" in m]
        if metascores:
            record_metrics["metascore"] = sum(metascores) / len(metascores)

        return record_metrics
evals/elsuite/modelgraded/classify_utils.py 全文
import logging
import string
from typing import Any, Callable, Iterable, Optional, Union

from evals import CompletionFn
from evals.elsuite.modelgraded.base import ModelGradedSpec
from evals.elsuite.utils import PromptFn, format_necessary, format_prompt
from evals.prompt.base import OpenAICreateChatPrompt, is_chat_prompt

INVALID_STR = "__invalid__"


ANSWER_PROMPTS = {
    # e.g. "Yes"
    "classify": "Answer the question by printing only a single choice from {choices} (without quotes or punctuation) corresponding to the correct answer with no other text.".strip(),
    # e.g. "Yes\n The reasons are: ..."
    "classify_cot": "First, answer by printing a single choice from {choices} (without quotes or punctuation) corresponding to the correct answer. Then, from the next line, explain your reasonings step by step.".strip(),
    # e.g. "Let's think step by step. ...\nYes"
    "cot_classify": """
First, write out in a step by step manner your reasoning to be sure that your conclusion is correct. Avoid simply stating the correct answer at the outset. Then print only a single choice from {choices} (without quotes or punctuation) on its own line corresponding to the correct answer. At the end, repeat just the answer by itself on a new line.

Reasoning:""".strip(),
    "cot_classify_jp": """
まず、一歩一歩あなたの推論を書き出してください。単に正しい答えを最初に述べることを避けてください。次に、{choices}(引用符や句読点なし)から正しい答えに対応する1つの選択肢を単独の行に書きだしてください。最後に、答えだけを新しい行に繰り返してください。

推論:
    """.strip(),
}
MATCH_FNS = {
    "include": lambda x, y: float(x in y),
    "exact": lambda x, y: float(x == y),
    "endswith": lambda x, y: x.endswith(y),
    "starts_or_endswith": lambda x, y: x.startswith(y) or x.endswith(y),
}


def get_choice_strings(choice_strings: Union[list[str], str], n: Optional[int] = None):
    # 'choice_strings' is a list of strings that specifies the possible choices
    if choice_strings == "from_n":
        choice_strings = [str(i + 1) for i in range(n)]
    elif choice_strings == "from_n_abc":
        choice_strings = [string.ascii_lowercase[i % 26] for i in range(n)]
    elif choice_strings == "from_n_ABC":
        choice_strings = [string.ascii_uppercase[i % 26] for i in range(n)]
    # make sure each choice doesn't contain any punctuation
    for s in choice_strings:
        assert not any(c in s for c in string.punctuation), f"{s} contains punctuation"
    return choice_strings


def classify(
    mg: ModelGradedSpec,
    completion_fn: CompletionFn,
    completion_kwargs: Optional[dict[str, Any]] = None,
    format_kwargs: Optional[dict[str, Any]] = None,
    eval_type: Optional[str] = None,
    n: Optional[int] = None,
    match_fn: Optional[str] = None,
) -> str:
    completion_kwargs = completion_kwargs or {}
    format_kwargs = format_kwargs or {}
    match_fn = match_fn or "starts_or_endswith"

    # get choice strings
    choice_strings = get_choice_strings(mg.choice_strings, n=n)

    # append answer prompt
    prompt = mg.prompt
    if isinstance(prompt, str):
        prompt = [{"role": "user", "content": prompt}]
    if eval_type:
        prompt = append_answer_prompt(
            prompt=prompt,
            eval_type=eval_type,
            choice_strings=choice_strings,
        )

    evaluate = PromptFn(prompt, completion_fn=completion_fn, **completion_kwargs)
    evaluation, prompt = evaluate(n=n, **format_kwargs)
    choice = get_choice(evaluation, mg.eval_type or eval_type, match_fn, choice_strings)
    score = get_choice_score(choice, choice_strings, mg.choice_scores)
    return choice, dict(
        score=score,
        sampled=[evaluation],
        prompt=prompt,
        invalid_choice=choice == INVALID_STR,
    )


def get_choice_score(
    choice: str,
    choice_strings: Iterable[str],
    choice_scores: Optional[Union[dict[str, float], str]] = None,
) -> Optional[float]:
    if choice_scores is None:
        return None
    if choice_scores == "from_strings":
        choice_scores = {c: float(c) for c in choice_strings}
    # assumption: each INVALID_STR contributes the lowest score
    if choice == INVALID_STR:
        return min(choice_scores.values())
    return choice_scores[choice]


def choice_to_str(choice_strings: Iterable[str]) -> str:
    """Return a string of choices, e.g. '"Yes" or "No" or "Maybe"'."""
    return " or ".join(f'"{choice}"' for choice in choice_strings)


def get_choice(
    text: str, eval_type: str, match_fn: Union[str, Callable], choice_strings: Iterable[str]
) -> str:
    """Clean the answer string to a choice string to one of choice_strings. Return '__invalid__.' if no match."""
    if isinstance(match_fn, str):
        match_fn = MATCH_FNS[match_fn]
    lines = text.strip().split("\n")
    if eval_type.startswith("cot_classify"):
        lines = lines[::-1]  # reverse lines
    for line in lines:
        line = line.strip()
        line = "".join(c for c in line if c not in string.punctuation)
        if not line:
            continue
        for choice in choice_strings:
            if match_fn(line, choice):
                return choice
    logging.warn(f"Choices {choice_strings} not parsable for {eval_type}: {text}")
    return INVALID_STR


def append_answer_prompt(
    prompt: OpenAICreateChatPrompt,
    eval_type: str,
    append_type: str = "as_content",
    answer_prompt: Optional[OpenAICreateChatPrompt] = None,
    choice_strings: Optional[Iterable[str]] = None,
) -> OpenAICreateChatPrompt:
    """Append answer prompt to prompt."""
    answer_prompt = answer_prompt or ANSWER_PROMPTS[eval_type]
    answer_prompt = format_prompt(answer_prompt, choices=choice_to_str(choice_strings))
    if append_type == "as_content":
        assert isinstance(answer_prompt, str), f"prompt must be str, not {type(answer_prompt)}"
        prompt[-1]["content"] += "\n\n" + answer_prompt
    elif append_type == "as_message":
        assert is_chat_prompt(answer_prompt), f"prompt must be chat prompt, not {answer_prompt}"
        prompt += answer_prompt
    else:
        raise ValueError(f"append_type must be 'as_content' or 'as_message', not {append_type}")
    return prompt


def sample_and_concat_n_completions(
    completion_fns: list[CompletionFn],
    prompt: OpenAICreateChatPrompt,
    n: int,
    template_i: str,
    sample_kwargs: dict,
):
    assert template_i
    completion_i_s = []
    for i in range(n):
        if len(completion_fns) > 1:
            # use a separate model for each completion
            assert len(completion_fns) == n
            completion_fn = completion_fns[i]
        else:
            # use the single model for all completions
            completion_fn = completion_fns[0]
        get_input_completion = PromptFn(prompt, completion_fn=completion_fn, **sample_kwargs)
        completion_i, _ = get_input_completion()
        completion_i_s.append(completion_i)
    return concat_n_completions(completion_i_s, template_i=template_i)


def concat_n_completions(completions: Iterable[str], template_i: str) -> str:
    """Concatenate n completions into a single text string."""
    completion = ""
    for i, completion_i in enumerate(completions):
        completion += format_necessary(
            template_i,
            i=i + 1,
            i_abc=string.ascii_lowercase[i % 26],
            i_ABC=string.ascii_uppercase[i % 26],
            output=completion_i,
            n=len(completions),
        )
    return completion.strip()
modelgraded/fact.yaml 与 closedqa.yaml
fact:
  prompt: |-
    You are comparing a submitted answer to an expert answer on a given question. Here is the data:
    [BEGIN DATA]
    ************
    [Question]: {input}
    ************
    [Expert]: {ideal}
    ************
    [Submission]: {completion}
    ************
    [END DATA]

    Compare the factual content of the submitted answer with the expert answer. Ignore any differences in style, grammar, or punctuation.
    The submitted answer may either be a subset or superset of the expert answer, or it may conflict with it. Determine which case applies. Answer the question by selecting one of the following options:
    (A) The submitted answer is a subset of the expert answer and is fully consistent with it.
    (B) The submitted answer is a superset of the expert answer and is fully consistent with it.
    (C) The submitted answer contains all the same details as the expert answer.
    (D) There is a disagreement between the submitted answer and the expert answer.
    (E) The answers differ, but these differences don't matter from the perspective of factuality.
  choice_strings: ABCDE
  input_outputs:
    input: completion
closedqa:
  prompt: |-
    You are assessing a submitted answer on a given task based on a criterion. Here is the data:
    [BEGIN DATA]
    ***
    [Task]: {input}
    ***
    [Submission]: {completion}
    ***
    [Criterion]: {criteria}
    ***
    [END DATA]
    Does the submission meet the criterion? First, write out in a step by step manner your reasoning about the criterion to be sure that your conclusion is correct. Avoid simply stating the correct answers at the outset. Then print only the single character "Y" or "N" (without quotes or punctuation) on its own line corresponding to the correct answer. At the end, repeat just the letter again by itself on a new line.

    Reasoning:
  eval_type: cot_classify
  choice_scores:
    "Y": 1.0
    "N": 0.0
  choice_strings: 'YN'
  input_outputs:
    input: "completion"

  1. openai/evals 的 docs/eval-templates.md,项目对各模板和打分参数的说明,2026-10-20 读。 ↩↩

  2. openai/evals 的 docs/build-eval.md,项目写的建 eval 流程,2026-10-20 读。 ↩