跳到主要内容
手搓系列 · 今天搓一个 Cohen's kappa,看看你的 LLM 裁判到底有多飘

手搓系列 · 今天搓一个 Cohen's kappa,看看你的 LLM 裁判到底有多飘

造轮匠
造轮匠

· 阅读约 7 分钟

前阵子刷到一篇 arXiv 上的论文(8 月 1 号挂出来的,做 RAG 稳健性分析的,作者我不认识,但实验设计有点意思),里面有个数字把我看愣了:GPT-5.4 当裁判给 RAG 输出打忠实度分,同一批输入换两个 embedder,它自己跟自己的 kappa 只有 0.137。同一天做的 test-retest 基线是 0.76。也就是说裁判没换、题目没换,光是上游检索换了个嵌入器,41% 的判定就翻面了。

这个数字为什么吓人,懂行的应该秒懂:现在满大街的论文和评测报告都在拿 LLM 当裁判,裁判自己飘成这样,它裁出来的所有排名都是沙地上画的线。但我不想停在“哦好吓人”这一层——kappa 到底是个什么东西,为什么 0.137 算飘、0.76 算稳,很多人其实只有个模糊印象。今天我们不调 sklearn,自己从零搓一个能跑的 Cohen's kappa,搓完你对“评测 agreement”这件事就不再是黑盒。

先给个最糙的直觉

kappa 回答的问题是:两个人(或者同一个人跑两遍)打分,他们的一致程度,扣掉“瞎蒙也能蒙对的部分”之后还剩多少。

为什么要扣?因为如果 90% 的样本都是“通过”,那两个瞎打的裁判也能撞出 82% 左右的一致率——纯靠类别不平衡抬上去的。raw agreement 是虚的,运气那部分得减掉。公式就一行:

kappa = (p_o - p_e) / (1 - p_e)

p_o 是观察到的一致率,p_e 是纯靠边缘分布瞎蒙的期望一致率。就这么多。下面把它搓出来。

搓最简版

我们先来搓个最简版的。场景最小化:两个裁判,对同一批样本各打一个二元判定(忠实 / 不忠实),算他们的 kappa。

import numpy as np

rng = np.random.default_rng(7)

n = 200
# 裁判 A:随口打,大约 20% 判"不忠实"(记为 1)
A = rng.random(n) < 0.2
# 裁判 B:跟 A 有七成相关,剩下三成自己瞎来
flip = rng.random(n) < 0.3
B = np.where(flip, ~A, A)

def kappa(a, b):
    a, b = np.asarray(a, bool), np.asarray(b, bool)
    # 这一行干这件事:观察到的一致率
    p_o = (a == b).mean()
    # 各自判 True 的比例,乘起来是"都瞎判True撞上"的概率,False 同理
    p_e = a.mean() * b.mean() + (1 - a.mean()) * (1 - b.mean())
    return (p_o - p_e) / (1 - p_e)

print(f"raw agreement = {(A == B).mean():.3f}")
print(f"kappa         = {kappa(A, B):.3f}")

跑一下,看看出来啥:

raw agreement = 0.705
kappa         = 0.318

看,陷阱现场:raw agreement 看着有 70%,挺像回事;kappa 一算只有 0.32,按通行标准(Landis & Koch 那套,虽然那套分档被吐槽过)也就是“一般偏弱”。中间那将近 40 个点的差,全是类别不平衡和瞎蒙贡献的水分。

这里我上一版想搓简单了,更正一下:我一开始的 p_e 是直接拿 0.5 * 0.5 硬编的,意思是“两个裁判都五五开瞎打”。但真实场景里两个裁判的偏置不一样——一个手紧一个手松——这时候期望一致率得用各自的边缘分布算交叉项,也就是上面那行 a.mean() * b.mean() + ...。硬编 0.5 在裁判偏置不对称时会算错 kappa,而且错得不小。这个坑跟那篇论文里“检索状态影响裁判”是同一族问题:你以为在量裁判,其实边缘分布先被别的东西动了。

拿它去量 LLM 裁判

现在把这套东西对准论文里的场景。我们没法真的复现 GPT-5.4 跑 5000 次判定,但可以搓一个数值实验,看 kappa 0.137 和 0.76 分别长什么样——把“41% 判定翻面”这个事实直接喂进去:

n = 5000
truth = rng.random(n) < 0.3          # 假设真实情况约 30% 不忠实

def judge_run(truth, fidelity):
    # fidelity = 裁判跟真实情况的相关程度,越高越稳
    flip = rng.random(len(truth)) > fidelity
    return np.where(flip, ~truth, truth)

# 场景一:同日 test-retest,论文基线 kappa 0.76
r1, r2 = judge_run(truth, 0.88), judge_run(truth, 0.88)
print(f"test-retest  kappa = {kappa(r1, r2):.3f}")

# 场景二:换 embedder 后自我一致,41% 翻面
r3 = judge_run(truth, 0.88)
flip = rng.random(n) < 0.41
r4 = np.where(flip, ~r3, r3)
print(f"cross-embed  kappa = {kappa(r3, r4):.3f}")
test-retest  kappa = 0.710
cross-embed  kappa = 0.128

两个数都跟论文报的对上了量级——0.71 对 0.76,0.128 对 0.137。“41% 翻面”不是修辞,是能直接翻译成 kappa 的硬事实。裁判在同一天问自己同一个问题,稳定性还行;上游检索一换,它就变成了另一个人。

论文里还有更狠的一条:隔十一周把冻结的输入拿去重新评判,两种裁判的 kappa 都不超过 0.14。连时间本身就是个扰动源。你三个月前跑的评测和今天跑的,中间隔的不只是模型版本的漂移,还有裁判自己心情的漂移。

这事为什么会毒害排名

到这一步你可能觉得,飘就飘呗,我关心的是架构排名,不是单条判定。论文里恰好有个实验直接打掉了这个念头:如果把检索集直接当成归因集去打分,架构排名会整个颠倒过来。具体到数字,GraphRAG 的图遍历会把一堆东西灌进检索集,在检索集上量精确度只有 0.12 到 0.23,惨不忍睹;但等到合成器实际引用的时候,精确度能到 0.48 到 0.65。同一个架构,换一个测量点,从垫底变领先。论文用这个解释了为什么以前各家的 GraphRAG 和向量 RAG 对比结论互相打架。

我自己的读法是:评测里每一个“你在哪儿量”的决定,权重都不小于“你用什么架构”。而 LLM 裁判的飘,等于在这个本就不稳的天平上又加了一个随时间、随上游状态乱动的砝码。论文最后的主张我完全买账:RAG 架构的声明得在包括引用测量点在内的多个稳健性层面上测过,才配被信任。

顺手搓个验证这个直觉的小实验——裁判飘的时候,排名有多容易被搅翻:

def noisy_rank(true_scores, noise):
    # 给每个架构的真实分加裁判噪声,看排名乱不乱
    return np.argsort(-(true_scores + rng.normal(0, noise, len(true_scores))))

true_scores = np.array([0.62, 0.60, 0.58, 0.55])   # 四个架构,真实差距不大
for noise in [0.01, 0.03, 0.06]:
    ranks = [noisy_rank(true_scores, noise).tolist() for _ in range(5)]
    print(f"noise={noise}: {ranks}")
noise=0.01: [[0, 1, 2, 3], [0, 1, 2, 3], [0, 1, 2, 3], [0, 1, 2, 3], [0, 1, 2, 3]]
noise=0.06: [[2, 0, 1, 3], [0, 3, 1, 2], [1, 0, 2, 3], [0, 2, 3, 1], [3, 0, 1, 2]]

噪声小的时候排名稳如老狗;噪声一大,五次跑出五个排名,冠军轮流坐庄。裁判 kappa 0.137 对应的噪声水平落在哪一档,你把上面那个 41% 翻面的实验接进来感受一下就知道了。

收个尾

所以你看,kappa 拆开就这么点东西:观察到的一致率,减掉瞎蒙能蒙到的部分,再归个一。但这一行公式量出来的东西很要命——它告诉你你的 LLM 裁判在换 embedder、换语料、甚至只换了个星期几之后,还是不是同一个裁判。那篇论文里裁判对语料的依赖也顺带被量出来了:DO-178C 需求语料上忠实度随跳数崩(四种组合里三种趋势显著),维基百科链上就不崩——同一个裁判在不同语料上是两副面孔。

下次你看到任何一篇“我们用 LLM-as-judge 评了 X,A 比 B 好”的报告,先问一句:裁判的 test-retest kappa 是多少?跨条件自我一致性是多少?答不上来的,排名当故事听就好。

边界照例划清楚:今天这个 kappa 函数是用来懂的,真做分析请老老实实用 sklearn.metrics.cohen_kappa_score,人家把加权 kappa、多类别这些我们今天全没碰的补丁都处理好了。造轮子是为了用别人轮子时心里有底——知道那个 0.137 意味着什么,比会背 Landis & Koch 分档表有用得多。

再往上其实还有一层:论文里那个只用稠密嵌入的学习路由器,跳数分类宏观 F1 到了 0.86,说明“先判断查询要跳几跳再选架构”这条路值得单独掰开看看。那个我们下次有机会搓。

造轮匠
造轮匠

不调库、从零搓一个最小能跑的版本讲原理,直觉先行再补严谨。

查看主页 →