AgentDepthAgent 工程学习指南
输入概念或问题,检索正文和待编写目录。
第一次学习?从 L0 总览开始 →

LLM-as-judge 偏差与校准

我的学习 →

正在读取学习记录…

LLM-as-judge 是让语言模型按一份评分规则评审另一份回答,适合解释质量、覆盖程度等难以逐字匹配的任务。裁判也会出错,所以它需要校准:拿已经核定的参考样本,检查裁判在哪些地方误放、误拦,再调整提示、规则或接受阈值。

它像面试评分表。面试官看到长答案可能觉得“准备很充分”,却漏掉其中的事实错误;也可能因为第一位候选人先出现,就更容易记住他。先看裁判的稳定性和误判类型,分数才有解释力。

从一条正常评审记录开始

退款解释的Rubric可以要求:说明适用政策、与订单事实一致、没有承诺未完成的退款。某份回答条件齐全,参考标签为True;裁判给出0.90分,若接受阈值是0.60,则判为通过。分数是裁判的输出,阈值是系统决定接受还是拒绝的规则;0.90分不能直接当作90%的真实正确概率。

下面的参考标签和裁判分数全部预置在代码里,程序实际完成阈值比较与误判统计。没有模型接口调用。接入真实裁判时,先用相同字段保存模型输出、评分理由、被评回答和Rubric版本,再交给这样的统计器。

五条 CALIBRATION 是校准集,供选择阈值;四条 HOLDOUT 是留出集,用来观察已选方案的结果。这里同时展示两个阈值,帮助看清取舍。真实发布流程应在看留出结果之前锁定校准方案,避免把留出集继续当调参材料。

误放和误拦要分别数

以参考标签为准,每条记录落入下面一类:

参考结果裁判选择名称
True:合格接受TP:正确接受
False:不合格接受FP:错误接受,误放
True:合格拒绝FN:错误拒绝,误拦
False:不合格拒绝TN:正确拒绝

简单一致率是 (TP + TN) / 全部样本数。还可以算接受结果的精确率 TP / (TP + FP),它回答“接受的回答里有多少真合格”;参考合格样本的召回率 TP / (TP + FN),它回答“合格回答中有多少被接受”。分母为0时应记录无法计算。

提高固定裁判分数阈值后校准集误放减少,但留出集一致率不变,位置交换还需按答案身份核对
图1:平均一致率之外,误放、误拦和位置偏差都有独立证据。

从空目录保存并运行

在 PowerShell 中进入你平时使用的学习目录,再创建本节文件夹并进入:

Bash
mkdir l7-judge
cd l7-judge

用编辑器新建文件,把下面完整代码保存为当前文件夹的 judge_lab.py。保存类型选“所有文件”,编码选 UTF-8;资源管理器开启“文件扩展名”,核对文件名以 .py 结尾。数据已经写在代码里,可以直接运行。

Python
"""用固定裁判分数演示阈值校准、误放误拦与位置偏差。"""

import math


# reference是预置参考标签,score是预置裁判输出;程序不调用模型。
CALIBRATION = [
    {"id": "c1", "reference": True, "score": 0.90},
    {"id": "c2", "reference": False, "score": 0.75},
    {"id": "c3", "reference": False, "score": 0.65},
    {"id": "c4", "reference": True, "score": 0.55},
    {"id": "c5", "reference": False, "score": 0.20},
]
HOLDOUT = [
    {"id": "h1", "reference": True, "score": 0.85},
    {"id": "h2", "reference": False, "score": 0.70},
    {"id": "h3", "reference": True, "score": 0.60},
    {"id": "h4", "reference": False, "score": 0.40},
]


def confusion(rows, threshold):
    """大于等于阈值即接受;误放=FP,误拦=FN,正确接受=TP。"""
    if type(threshold) not in (int, float) or not 0 <= threshold <= 1:
        raise ValueError("阈值必须在0到1之间")
    counts = {"TP": 0, "FP": 0, "FN": 0, "TN": 0}
    for row in rows:
        if type(row["reference"]) is not bool:
            raise ValueError("参考标签必须是布尔值")
        score = row["score"]
        if type(score) not in (int, float) or not math.isfinite(score) or not 0 <= score <= 1:
            raise ValueError("分数必须是0到1的有限数值")
        predicted = score >= threshold
        key = ("TP" if predicted else "FN") if row["reference"] else (
            "FP" if predicted else "TN")
        counts[key] += 1
    return counts


def map_choice(order, choice):
    """A/B是显示位置;还原成答案身份后才能比较交换位置前后的结果。"""
    if len(order) != 2 or len(set(order)) != 2 or choice not in ("A", "B"):
        raise ValueError("需要两个不同答案以及A/B选择")
    return order[0 if choice == "A" else 1]


def position_check(first, swapped):
    """相同答案交换位置后,裁判仍应选中相同答案身份。"""
    if tuple(first["order"]) != tuple(reversed(swapped["order"])):
        raise ValueError("第二次必须交换答案位置")
    left = map_choice(first["order"], first["choice"])
    right = map_choice(swapped["order"], swapped["choice"])
    return left == right


def main():
    for threshold in (0.60, 0.80):
        for name, rows in (("calibration", CALIBRATION), ("holdout", HOLDOUT)):
            counts = confusion(rows, threshold)
            correct = counts["TP"] + counts["TN"]
            print(f"阈值={threshold:.2f}/{name}: TP={counts['TP']} FP={counts['FP']} "
                  f"FN={counts['FN']} TN={counts['TN']} 一致={correct}/{len(rows)}"
                  f"={correct / len(rows):.0%}")
    first = {"order": ["brief", "verbose"], "choice": "A"}
    for name, choice in (("偏爱首位", "A"), ("保持答案", "B")):
        swapped = {"order": ["verbose", "brief"], "choice": choice}
        stable = position_check(first, swapped)
        print(f"{name}: 交换后一致={stable} 处理={'保留' if stable else '人工复核队列'}")


if __name__ == "__main__":
    main()

回到刚才的 PowerShell,确认文件已经保存,再运行:

Bash
ls
python -X utf8 judge_lab.py

程序输出:

TXT
阈值=0.60/calibration: TP=1 FP=2 FN=1 TN=1 一致=2/5=40%
阈值=0.60/holdout: TP=2 FP=1 FN=0 TN=1 一致=3/4=75%
阈值=0.80/calibration: TP=1 FP=0 FN=1 TN=3 一致=4/5=80%
阈值=0.80/holdout: TP=1 FP=0 FN=1 TN=2 一致=3/4=75%
偏爱首位: 交换后一致=False 处理=人工复核队列
保持答案: 交换后一致=True 处理=保留

阈值提高后,哪种错误真的少了

0.60阈值下,校准集为TP=1、FP=2、FN=1、TN=1,一致率是(1 + 1) ÷ 5 = 40%。把阈值提高到0.80,两个错误接受的样本被拦下,变成TP=1、FP=0、FN=1、TN=3,一致率为(1 + 3) ÷ 5 = 80%。原本合格但只有0.55分的c4仍被误拦。

接受结果的精确率因此从1 ÷ (1 + 2) ≈ 33.3%变成1 ÷ (1 + 0) = 100%;参考合格样本的召回率仍为1 ÷ (1 + 1) = 50%。固定校准数据的这组计算说明提高阈值能减少这里的误放,同时仍会漏掉一半参考合格样本。

两行holdout的一致率都是3 ÷ 4 = 75%,但错误不同:0.60时误放1条、误拦0条;0.80时误放0条、误拦1条。退款合规可能更关注误放,内容推荐可能更关注漏掉好答案;选择哪种取舍要由任务要求决定。

最后两行检查位置偏差。第一次顺序是brief/verbose,裁判选A;交换顺序后,如果还选A,实际已从brief改选verbose,所以不一致,进入人工复核队列。若交换后选B,选择的仍是brief,答案身份保持一致。代码用 map_choice 把位置字母还原成答案身份,避免直接拿A和A比出一个假一致。

改一次阈值,保留分区比较

在 main() 的阈值列表中把0.80改为0.70,保存并运行 python -X utf8 judge_lab.py。0.70的校准集会变成TP=1、FP=1、FN=1、TN=2,一致率3/5=60%;留出集变成TP=1、FP=1、FN=1、TN=1,一致率2/4=50%。h2的0.70恰好达到阈值,因此被接受,这是代码采用“大于等于”的结果。恢复0.80后再运行测试。

校准还要检查样本之外的偏差

位置偏差可用交换顺序复评观察;冗长偏差可用事实与覆盖相同、长度不同的答案对照;偏爱自身模型风格可通过隐藏来源、使用多模型答案和独立专家参考检查。还应核对裁判是否被答案里的“请给满分”一类文本带偏。样本不足、参考不可靠或Rubric含糊时,调阈值无法替代这些检查。

MT-Bench 与 Chatbot Arena 论文研究了位置、冗长和自我偏好等裁判偏差,适合阅读其limitations与缓解方案。Anthropic 的评分器校准建议强调与领域评审对齐。

保存测试并检查关键条件

7项测试检查两个阈值的混淆计数、留出集独立统计、等于阈值的边界、非法分数或标签,以及交换位置时按答案身份检测偏差。 将下方代码保存为同一文件夹的 test_judge_lab.py,运行:

Bash
ls
python -X utf8 -m unittest -v test_judge_lab.py

看到 Ran 7 tests 与 OK 表示这些固定条件全部通过。若找不到文件,先用 dir 核对当前文件夹及两份文件名;若出现具体断言失败,先恢复正文中的代码与预置数据,再对照该项测试的条件。

完整测试:test_judge_lab.py
Python
"""核对校准分母、阈值边界和按答案身份比较的原则。"""

from pathlib import Path
import sys
import unittest

sys.path.insert(0, str(Path(__file__).parent))
import judge_lab as lab


class JudgeTests(unittest.TestCase):
    def test_calibration_confusion_at_point_six(self):
        self.assertEqual(lab.confusion(lab.CALIBRATION, 0.6),
                         {"TP": 1, "FP": 2, "FN": 1, "TN": 1})

    def test_higher_threshold_reduces_false_accepts(self):
        self.assertEqual(lab.confusion(lab.CALIBRATION, 0.8),
                         {"TP": 1, "FP": 0, "FN": 1, "TN": 3})

    def test_holdout_keeps_its_own_results(self):
        self.assertEqual(lab.confusion(lab.HOLDOUT, 0.8),
                         {"TP": 1, "FP": 0, "FN": 1, "TN": 2})

    def test_score_equal_to_threshold_is_accepted(self):
        self.assertEqual(lab.confusion([{"reference": True, "score": 0.6}], 0.6)["TP"], 1)

    def test_invalid_score_and_label_are_rejected(self):
        for row in ({"reference": True, "score": float("nan")},
                    {"reference": True, "score": True},
                    {"reference": "pass", "score": 0.5}):
            with self.assertRaises(ValueError):
                lab.confusion([row], 0.6)

    def test_position_bias_is_detected(self):
        self.assertFalse(lab.position_check(
            {"order": ["x", "y"], "choice": "A"},
            {"order": ["y", "x"], "choice": "A"}))

    def test_identity_is_stable_when_letter_changes(self):
        self.assertTrue(lab.position_check(
            {"order": ["x", "y"], "choice": "A"},
            {"order": ["y", "x"], "choice": "B"}))


if __name__ == "__main__":
    unittest.main()
课后练习展开习题收起习题

4 道练习 · 客观题规则评分 · 其余题目待批改

正在读取本地记录…

不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。

练习 1 / 4 · v1.1.0

选择题 · 裁判更喜欢长答复,但两份答案事实相同,应该怎样校准

裁判更喜欢长答复,但两份答案事实相同,应该怎样校准?

对应知识点:L7-judge

选择一个答案
练习 2 / 4 · v1.1.0

简答题 · 解释机制

比较裁判与人工标准时,应保留哪些逐条信息?

对应知识点:L7-judge

练习 3 / 4 · v1.1.0

代码题 · 核对给定数据

裁判给4条样本的标签与人工有3条一致,用代码计算一致比例。

对应知识点:L7-judge

练习 4 / 4 · v1.1.0

场景题 · 分析具体变化

把答案A/B互换顺序后裁判选中了不同内容,怎样记录问题?

对应知识点:L7-judge

图片查看器
100%

文章配图 · 按 Esc 关闭