新 prompt 从 82% 涨到 86%,是真的变好了吗?
两个版本跑的是同一批用例,要按用例配对比。100 条用例里 3 条变差、7 条变好,McNemar 精确检验 p = 0.34,还不能说变好了。
只有"两个版本结果不一样"的用例带信息。变差的那几条本身就是回归清单,要逐条看。
1场景:两道面试题其实是同一个问题
- "改了 prompt,怎么证明没弄坏其他用例?"(第 1 题)面试时答的是"重新跑一遍就可以了"。跑一遍能拿到新通过率,但回答不了"这个差是真的还是噪声"。
- "能不能把 Opus 换成便宜模型?"(第 4 题)要证明的是"便宜模型没有明显变差",比证明"变好了"更难。
两道题的数据长得一样:同一批 N 条用例,旧版本跑一遍,新版本跑一遍。旧版 82 条过,新版 86 条过。
前几章都在给一个通过率算区间:0 失败用 rule of three,有失败用 Wilson。这一章比两个通过率。直觉做法是各算一个 Wilson 区间看重不重叠,或者套"两个比例的 z 检验"。这两种做法都把两组结果当成互不相关的两批样本,扔掉了最有用的信息:它们是同一批用例。
2配对数据:先画 2×2 表
每条用例都有一对结果(旧版过没过,新版过没过),一共四种情况:
| 新版过 | 新版挂 | 合计 | |
|---|---|---|---|
| 旧版过 | a = 79 都过 | b = 3 旧过新挂 | 82 |
| 旧版挂 | c = 7 旧挂新过 | d = 11 都挂 | 18 |
| 合计 | 86 | 14 | 100 |
通过率的差 = \((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 + c | McNemar 精确 p | 不配对 z 检验 p |
|---|---|---|---|---|
| 0 | 4 | 4 | 0.125 | 0.44 |
| 3 | 7 | 10 | 0.344 | 0.44 |
| 10 | 14 | 24 | 0.541 | 0.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。
- \(P(X \le 3) = (1 + 10 + 45 + 120)/1024 = 176/1024 \approx 0.172\)
- 双侧:\(p = 2 \times 0.172 \approx 0.344\)
也就是说,两个版本真的一样好时,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 |
|---|---|---|---|
| 0 | 6 | 82% → 88% | 0.031 |
| 1 | 8 | 82% → 89% | 0.039 |
| 3 | 12 | 82% → 91% | 0.035 |
哪怕一条都没弄坏,修好 4 条(p = 0.125)、5 条(p = 0.063)都不够。在 100 条用例上,+4 个百分点基本证明不了什么。
4配对 bootstrap:给"差多少"一个区间
p 值只回答"是不是噪声"。汇报时更有用的是差值的区间:新版比旧版高多少,最少可能高多少,最多可能高多少。bootstrap 不用推公式就能算:
- 把每条用例的那一对结果(旧, 新)当成一个单位。
- 从 N 条用例里有放回地抽 N 条,算一次"新通过率 − 旧通过率"。
- 重复 10000 次,得到 10000 个差值。
- 取第 2.5 和第 97.5 百分位,就是 95% 区间。
关键在第 1 步:抽中一条用例,就同时带上它的旧结果和新结果。如果旧版、新版各自独立地抽,就又变回不配对的比较。
例子:a = 79,b = 3,c = 7,d = 11。配对 bootstrap 的 95% 区间约为 [−2, +10] 个百分点,包含 0,和 McNemar 的结论一致:可能提升了 10 个点,也可能倒退了 2 个点。
▶互动演示:填 2×2 表,实时算 p 值和区间
改表里任意一格,下面的数字全部重算。bootstrap 用固定种子重抽 2000 次,点"换个种子"看区间抖多少。
| 新版过 | 新版挂 | |
|---|---|---|
| 旧版过 | a 都过 | b 旧过新挂(回归) |
| 旧版挂 | c 旧挂新过(修好) | d 都挂 |
原假设下,分歧里新版赢几次
通过率差的 95% 区间
5为什么配对更灵敏
用例之间难度差别很大:难的用例两个版本都挂,简单的两个版本都过。不配对的检验把这种"用例难度"的波动也算进了噪声;配对比较让每条用例和自己比,难度在相减时抵消。
看一组数:500 条用例,82% → 86%(410 → 430 条),b = 10,c = 30。
| 方法 | 用到的信息 | p 值 | 结论 |
|---|---|---|---|
| 不配对双比例 z 检验 | 410/500 和 430/500 | 0.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 的比例(检出率)。
6每条用例跑多次:按用例重抽,不按单次运行
模型输出有随机性,一条用例只跑一次,b 里可能混着 flaky 的用例。所以实际评测常常每条用例跑 m 次,每条用例得到一个通过率而不是 0/1。
这时 McNemar 不能直接用(它要求每条用例一对 0/1)。bootstrap 照样能用,但抽样单位必须是用例:
- 先算每条用例的"新版通过率 − 旧版通过率",得到 N 个差值。
- 在这 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%。
▶模拟:每条用例跑 m 次,按用例重抽 vs 按单次运行重抽
40 条用例,每条随机属于三类之一:稳定(两个版本都 90%)、被修好(20% → 90%)、被改坏(90% → 20%)。下面每一格是一条用例,颜色是它的"新 − 旧"通过率。
点"重复 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 按用例重抽,给出差值的区间。每条用例跑多次时按用例重抽。旧过新挂的用例是回归清单,要逐条看。
常见错误说法
❌ "两个 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 源码,交互部分不受影响。