新 prompt 从 82% 涨到 86%,是真的变好了吗?

两个版本跑的是同一批用例,要按用例配对比。100 条用例里 3 条变差、7 条变好,McNemar 精确检验 p = 0.34,还不能说变好了。

只有"两个版本结果不一样"的用例带信息。变差的那几条本身就是回归清单,要逐条看。

1场景:两道面试题其实是同一个问题

两道题的数据长得一样:同一批 N 条用例,旧版本跑一遍,新版本跑一遍。旧版 82 条过,新版 86 条过。

前几章都在给一个通过率算区间:0 失败用 rule of three,有失败用 Wilson。这一章比两个通过率。直觉做法是各算一个 Wilson 区间看重不重叠,或者套"两个比例的 z 检验"。这两种做法都把两组结果当成互不相关的两批样本,扔掉了最有用的信息:它们是同一批用例。

2配对数据:先画 2×2 表

每条用例都有一对结果(旧版过没过,新版过没过),一共四种情况:

新版过新版挂合计
旧版过a = 79 都过b = 3 旧过新挂82
旧版挂c = 7 旧挂新过d = 11 都挂18
合计8614100

通过率的差 = \((a+c)/N - (a+b)/N = (c-b)/N\)。a 和 d 在相减时抵消了:都过的 79 条、都挂的 11 条,对"哪个版本更好"一点信息都没有。

所以真正的样本量不是 100,而是 b + c = 10 条"结果不一样"的用例。问题变成:10 次分歧里新版赢 7 次,是不是运气?

净增一样,证据可以差很多

同样是 82% → 86%,2×2 表可以长得完全不一样:

b(旧过新挂)c(旧挂新过)分歧 b + cMcNemar 精确 p不配对 z 检验 p
0440.1250.44
37100.3440.44
1014240.5410.44

第一行:新版只修好了 4 条,没弄坏任何一条。第三行:修好 14 条,同时弄坏了 10 条,新版更像"换了一种挂法"。不配对的检验只看 82 和 86 两个数,三行给出同一个 p,分不出这两种情况。

3McNemar 检验:b + c 次分歧里,c 占多少

原假设:两个版本一样好。那么每一次分歧,偏向新版还是偏向旧版的可能性一样,像抛一枚均匀硬币。

在 \(n = b + c\) 次分歧里,新版赢的次数 \(X \sim \text{Binomial}(n,\ 0.5)\)。双侧 p 值是"至少和观测一样偏"的概率:

$$p = \min\Bigl(1,\ 2\sum_{i=0}^{\min(b,c)} \binom{n}{i} 0.5^{n}\Bigr)$$

例子:b = 3,c = 7,n = 10。

也就是说,两个版本真的一样好时,10 次分歧里出现 7:3 或更偏的结果,概率约 34%。这太常见了,不能当证据。

大样本近似:卡方统计量

$$\chi^2 = \frac{(|b-c|-1)^2}{b+c} \quad (\text{自由度 } 1)$$

分子里的 −1 是连续性校正:用连续的卡方分布去近似离散的二项分布时,往保守方向修一点。上面的例子:\(\chi^2 = (4-1)^2/10 = 0.9\),p ≈ 0.343,和精确值几乎一样。

b + c 小于 25 左右时用精确检验。现在电脑算精确值不费事,直接用精确检验最省心(statsmodels 的 mcnemar 默认就是精确版)。

100 条用例,要多少分歧才算数

弄坏 b 条至少要修好 c 条对应的通过率变化精确 p
0682% → 88%0.031
1882% → 89%0.039
31282% → 91%0.035

哪怕一条都没弄坏,修好 4 条(p = 0.125)、5 条(p = 0.063)都不够。在 100 条用例上,+4 个百分点基本证明不了什么。

p > 0.05 的意思是"证据不够",不是"两个版本一样"。这和 rule of three 那章是同一个逻辑:没看到失败,不等于没有失败。

4配对 bootstrap:给"差多少"一个区间

p 值只回答"是不是噪声"。汇报时更有用的是差值的区间:新版比旧版高多少,最少可能高多少,最多可能高多少。bootstrap 不用推公式就能算:

  1. 把每条用例的那一对结果(旧, 新)当成一个单位。
  2. 从 N 条用例里有放回地抽 N 条,算一次"新通过率 − 旧通过率"。
  3. 重复 10000 次,得到 10000 个差值。
  4. 取第 2.5 和第 97.5 百分位,就是 95% 区间。

关键在第 1 步:抽中一条用例,就同时带上它的旧结果和新结果。如果旧版、新版各自独立地抽,就又变回不配对的比较。

例子:a = 79,b = 3,c = 7,d = 11。配对 bootstrap 的 95% 区间约为 [−2, +10] 个百分点,包含 0,和 McNemar 的结论一致:可能提升了 10 个点,也可能倒退了 2 个点。

分歧很少时 bootstrap 不可靠。b = 0、c = 4 时,重抽永远抽不出"旧过新挂",区间约为 [+1, +8],看起来"显著",可精确检验 p = 0.125。b + c 很小时以精确检验为准。

▶互动演示:填 2×2 表,实时算 p 值和区间

改表里任意一格,下面的数字全部重算。bootstrap 用固定种子重抽 2000 次,点"换个种子"看区间抖多少。

新版过新版挂
旧版过 a 都过 b 旧过新挂(回归)
旧版挂 c 旧挂新过(修好) d 都挂
场景
通过率 旧 → 新
McNemar 精确 p
卡方(连续性校正)p
不配对 z 检验 p
只看两个通过率

原假设下,分歧里新版赢几次

Binomial(b + c, 0.5) 的概率 和观测一样偏或更偏(两侧加起来 = 精确 p,超过 1 时记为 1) 观测到的 c

通过率差的 95% 区间

5为什么配对更灵敏

用例之间难度差别很大:难的用例两个版本都挂,简单的两个版本都过。不配对的检验把这种"用例难度"的波动也算进了噪声;配对比较让每条用例和自己比,难度在相减时抵消。

看一组数:500 条用例,82% → 86%(410 → 430 条),b = 10,c = 30。

方法用到的信息p 值结论
不配对双比例 z 检验410/500 和 430/5000.084不显著
McNemar 精确检验40 次分歧里新版赢 30 次0.002显著

同一份数据,结论相反。不配对的做法要检出 82% → 86%(80% 把握、双侧 α = 0.05),每个版本得跑约 1300 条用例。

配对的方差近似是 \((b+c)/N^2\),只和分歧多少有关;不配对的方差是 \([p_1(1-p_1) + p_2(1-p_2)]/N\),只取决于两个通过率,不管用例难度怎么分布都一样;难度造成的相关性只会让配对方差变小。两个版本在同一条用例上的结果越一致,配对的优势越大。Agent 评测里,这种一致性几乎总是存在:难用例对两个版本都难。反过来,如果两个版本在同一条用例上各挂各的,分歧率高到和"两个互不相关的版本"差不多,配对就没有优势。可以在下面的模拟里把分歧率拖大试试。

这是"独立性假设"那章的另一面:同一条用例的多次结果相关,会让"按独立算"的区间太窄;两个版本在同一条用例上的结果相关,反过来能让配对比较更灵敏。相关性不是坏事,关键是分析方法要和数据结构对得上。

▶模拟:给定真实提升,配对和不配对各能检出几次

设定真实的旧版通过率、真实提升和分歧率,按这个设定重复做 1000 次实验。每次实验 N 条用例,同一份数据分别用 McNemar 精确检验和不配对 z 检验,统计 p < 0.05 的比例(检出率)。

场景

配对:McNemar 精确检验
不配对:双比例 z 检验

6每条用例跑多次:按用例重抽,不按单次运行

模型输出有随机性,一条用例只跑一次,b 里可能混着 flaky 的用例。所以实际评测常常每条用例跑 m 次,每条用例得到一个通过率而不是 0/1。

这时 McNemar 不能直接用(它要求每条用例一对 0/1)。bootstrap 照样能用,但抽样单位必须是用例:

  1. 先算每条用例的"新版通过率 − 旧版通过率",得到 N 个差值。
  2. 在这 N 个差值上做 bootstrap:有放回抽 N 条用例,求平均。

错误做法是把 N × m 次运行摊平,按单次运行重抽。这就是"独立性假设"那章说的:同一条用例的 m 次结果高度相关,摊平等于把样本量虚报了好几倍。

例子:40 条用例,两个版本各跑 10 次。30 条两个版本都稳(通过率都是 90%),7 条被新 prompt 修好(20% → 90%),3 条被改坏(90% → 20%)。一次模拟的结果:旧 75.8% → 新 86.0%。

重抽单位95% 区间结论
按用例(40 个单位)[−0.8, +21.0] 个百分点包含 0,证据不够
按单次运行(400 个单位)[+4.8, +15.8] 个百分点"显著",但这是假象

整个提升来自 10 条用例,按单次运行重抽假装有 400 个独立样本。把同样的设定重复 2000 次(每次重新抽用例),按单次运行重抽的 95% 区间只有约 64% 的时候罩住真实差值;按用例重抽约 94%。

多跑几次的价值在于分清 flaky 和真回归:b 里的一条用例,旧版 10 次过 9 次、新版 10 次过 2 次,是真回归;新版 10 次过 8 次,多半只是那一次运气不好。这里用的就是 pass^k 那章的思路。

▶模拟:每条用例跑 m 次,按用例重抽 vs 按单次运行重抽

40 条用例,每条随机属于三类之一:稳定(两个版本都 90%)、被修好(20% → 90%)、被改坏(90% → 20%)。下面每一格是一条用例,颜色是它的"新 − 旧"通过率。

0.7 ×(修好占比 − 改坏占比)
新版更好 新版更差 差不多
按用例重抽:覆盖率
—
按单次运行重抽:覆盖率
—

点"重复 300 次":每次重新抽 40 条用例、重新跑,看两种 95% 区间各有多少次罩住真实差值。金色竖线是 95%。

7回归清单,和"换便宜模型"

b 不只是一个数字

2×2 表里的 b 是一份名单:这几条用例,旧版过、新版挂。整体通过率涨了,不代表可以忽略它们。逐条看,归到三类:

类型怎么判断怎么处理
真回归新版重跑多次还是挂,旧版重跑多次都过定位原因,修 prompt 或加规则;这条进回归集
flaky两个版本重跑都时过时挂不算在这次改动头上,另开问题查不稳定
用例 / 判定有问题新版输出其实是对的,断言或 Judge 判错了修用例,重新算表

面试第 1 题的好答案是:"旧版和新版在同一批用例上各跑 k 次,按用例配对比较。整体用 McNemar 或配对 bootstrap 判断是不是真变好;旧过新挂的用例单独列出来逐条看,关键用例一条都不许回归。"

换便宜模型:要证明的是"没有明显变差"

200 条用例,Opus 88%,便宜模型 87%,b = 9,c = 7。McNemar p = 0.80,"不显著"。能说"便宜模型一样好"吗?

不能。配对 bootstrap 的 95% 区间约为 [−5, +3] 个百分点:便宜模型可能差 5 个点。

正确的做法叫非劣效(non-inferiority):事先定一个能接受的下降幅度,比如 3 个百分点,然后要求差值区间的下界 > −3。这里下界是 −5,证据不够,要么加用例,要么接受"可能差 5 个点"这个风险。正式写法是单侧检验:单侧 α = 0.025(药监常用标准)等价于看 95% 双侧区间的下界,也就是上面的做法;如果事先约定单侧 α = 0.05,则等价于看 90% 双侧区间的下界。

"没检出差异"和"证明没有差异"是两回事。样本越小越容易"不显著",不能拿样本小当作换模型的理由。另外,成本也要一起报:单次成本从多少降到多少,换来的是多大的质量风险。

8面试要点与代码

一句话讲清楚

两个版本跑同一批用例,是配对数据。只有结果不一样的用例带信息:旧过新挂 b 条、旧挂新过 c 条。McNemar 检验看 b + c 次分歧里 c 占多少,小样本用精确二项检验;配对 bootstrap 按用例重抽,给出差值的区间。每条用例跑多次时按用例重抽。旧过新挂的用例是回归清单,要逐条看。

常见错误说法

❌ "新版 86% 比旧版 82% 高,所以新 prompt 更好":100 条用例上 +4 个点,就算一条都没弄坏(b = 0,c = 4),McNemar 精确 p 也有 0.125;弄坏的越多越不显著。
❌ "两个 Wilson 区间重叠,所以没有差异":各算各的区间是不配对的做法,扔掉了配对信息,也不是正式的检验。
❌ "p = 0.8,所以便宜模型和 Opus 一样好":不显著不等于没差异,要看差值区间的下界。
❌ "整体涨了,旧过新挂的几条是噪声":没逐条看之前,不知道它们是 flaky 还是真回归。
❌ "每条用例跑 10 次,bootstrap 按 400 次运行重抽":抽样单位必须是用例,否则区间窄得离谱。
import numpy as np
from scipy.stats import binomtest, chi2


def mcnemar(b: int, c: int) -> dict:
    """b = 旧过新挂,c = 旧挂新过。返回精确 p 值和带连续性校正的卡方 p 值(双侧)。"""
    if b < 0 or c < 0:
        raise ValueError(f"b、c 不能为负,收到 b={b}, c={c}")
    if b + c == 0:
        return {"exact_p": 1.0, "chi2_cc_p": 1.0}
    exact_p = binomtest(c, b + c, 0.5).pvalue           # b+c 次不一致里,c 次偏向新版
    stat = max(abs(b - c) - 1, 0) ** 2 / (b + c)          # 连续性校正
    return {"exact_p": exact_p, "chi2_cc_p": chi2.sf(stat, df=1)}


def paired_bootstrap(old: np.ndarray, new: np.ndarray, n_boot: int = 10_000, seed: int = 0):
    """old/new: 每条用例的通过情况(0/1 或多次运行的通过率),同一下标 = 同一条用例。
    按用例整行重抽,返回 (新 - 旧) 通过率差的 95% 区间。"""
    if old.shape != new.shape or old.ndim != 1 or old.size == 0:
        raise ValueError("old 和 new 需要是等长的一维非空数组,同一下标对应同一条用例")
    rng = np.random.default_rng(seed)
    diff = new - old                                       # 先按用例配对相减
    idx = rng.integers(0, diff.size, size=(n_boot, diff.size))
    return np.percentile(diff[idx].mean(axis=1), [2.5, 97.5])


# 例 1:100 条用例各跑 1 次,82% -> 86%
old = np.array([1] * 79 + [1] * 3 + [0] * 7 + [0] * 11)  # 都过 79、旧过新挂 3、旧挂新过 7、都挂 11
new = np.array([1] * 79 + [0] * 3 + [1] * 7 + [0] * 11)
b = int(((old == 1) & (new == 0)).sum())
c = int(((old == 0) & (new == 1)).sum())
print(f"b={b}, c={c}", {k: round(v, 3) for k, v in mcnemar(b, c).items()})
print("配对 bootstrap 95% 区间:", np.round(paired_bootstrap(old, new) * 100, 1))

# 例 2:40 条用例,旧版、新版各跑 10 次。30 条本来就稳、7 条被新 prompt 修好、3 条被改坏
rng = np.random.default_rng(0)
p_old = np.r_[np.full(30, 0.9), np.full(7, 0.2), np.full(3, 0.9)]
p_new = np.r_[np.full(30, 0.9), np.full(7, 0.9), np.full(3, 0.2)]
runs_old = rng.random((40, 10)) < p_old[:, None]           # 形状 (用例数, 重跑次数)
runs_new = rng.random((40, 10)) < p_new[:, None]
print(f"旧 {runs_old.mean():.1%} -> 新 {runs_new.mean():.1%}")
print("按用例重抽(对):", np.round(paired_bootstrap(runs_old.mean(1), runs_new.mean(1)) * 100, 1))
# 错误示范:400 次运行摊平当独立样本;按运行序号把旧版第 j 次和新版第 j 次配成一对,本身也没有意义
print("按单次运行重抽(错):", np.round(paired_bootstrap(runs_old.ravel() * 1.0, runs_new.ravel() * 1.0) * 100, 1))
b=3, c=7 {'exact_p': 0.344, 'chi2_cc_p': 0.343}
配对 bootstrap 95% 区间: [-2. 10.]
旧 75.8% -> 新 86.0%
按用例重抽(对): [-0.8 21. ]
按单次运行重抽(错): [ 4.8 15.8]

上面是 scipy 1.8 / numpy 1.22 下的实际输出。binomtest 需要 scipy ≥ 1.7;更老的版本用 binom_test。公式显示依赖 KaTeX(CDN),断网时公式会显示成原始 LaTeX 源码,交互部分不受影响。

9练习

题 1(读表):200 条用例,旧版过 150 条,新版过 158 条,两个版本都过的有 145 条。旧过新挂 b 和旧挂新过 c 各是多少?
b = c =
b = 150 − 145 = 5,c = 158 − 145 = 13。净增 c − b = 8,正好是 158 − 150。
题 2(算):b = 2,c = 8。McNemar 精确检验的双侧 p 值是多少?(填百分数)
%
n = 10,P(X ≤ 2) = (1 + 10 + 45)/1024 = 56/1024,双侧 p = 112/1024 ≈ 10.9%。修好 8 条、弄坏 2 条,还是不够。可以在上面的演示里填 b = 2、c = 8 验证。
题 3(概念):100 条用例,82% → 86%。下面哪张表给出的"新版更好"的证据最强?
McNemar 只看分歧。0:4 的精确 p = 0.125,3:7 是 0.344,10:14 是 0.541。不配对的检验只看 82 和 86,三张表给出同一个 p = 0.44,这正是它的问题。
题 4(场景):想把 Opus 换成便宜模型。200 条用例上 88% vs 87%,McNemar p = 0.80。怎么汇报?
不显著只说明"没检出差异",不说明"没有差异"。换模型是非劣效问题:事先定可接受的下降幅度,看区间下界。B 把 1 个点的点估计当成了确定的差距,同样没考虑噪声。
题 5(概念):40 条用例,两个版本各跑 10 次,要算通过率差的 bootstrap 区间。重抽单位应该是什么?
同一条用例的 10 次结果高度相关,按单次运行抽等于把样本量虚报了(独立性假设那章)。C 同时丢了配对和聚类两层结构。上面的模拟里,m = 10 时按单次运行重抽的覆盖率只有六成多。