第 07 章
第 1 周:新 prompt 从 82% 涨到 86%,是真的变好了吗?
对比两个版本:同一批用例是配对数据,只有结果不一样的用例带信息。McNemar 精确检验、配对 bootstrap、每条用例跑多次时按用例重抽。一段讲解视频,一个可以改 2×2 表、跑模拟的互动演示。
改了 prompt,同一批 100 条用例各跑一遍:旧版 82 条过,新版 86 条过。涨了 4 个百分点,是真变好了,还是噪声?换便宜模型也是同一个问题,只是方向反过来:要证明它没有明显变差。这一章讲怎么比较两个版本:先画 2×2 表,再用 McNemar 检验和配对 bootstrap。
讲解视频
互动演示
三个演示:改 2×2 表里的任意一格,实时算 McNemar 精确 p 值和配对 bootstrap 区间;设定真实提升,重复实验看配对和不配对各能检出几次;每条用例跑多次时,对比按用例重抽和按单次运行重抽的区间。页面底部有自动判分的练习。
两道面试题,同一个问题
- “改了 prompt,怎么证明没弄坏其他用例?” 面试时答的是"重新跑一遍就可以了"。跑一遍能拿到新通过率,但回答不了"这个差是真的还是噪声"。
- “能不能把 Opus 换成便宜模型?” 要证明的是"便宜模型没有明显变差",比证明"变好了"更难。
两道题的数据长得一样:同一批 N 条用例,旧版本跑一遍,新版本跑一遍。
前几章都在给一个通过率算区间:0 失败用 rule of three,有失败用 Wilson。这一章比两个通过率。直觉做法是各算一个 Wilson 区间看重不重叠,或者套"两个比例的 z 检验"。这两种做法都把两组结果当成互不相关的两批样本,扔掉了最有用的信息:它们是同一批用例。
配对数据:先画 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,分不出这两种情况。
McNemar 检验
原假设:两个版本一样好。那么每一次分歧偏向新版还是偏向旧版,可能性一样,像抛一枚均匀硬币。
在 \(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 个百分点基本证明不了什么。
p > 0.05 的意思是"证据不够",不是"两个版本一样"。这和 rule of three 那章是同一个逻辑:没看到失败,不等于没有失败。
配对 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 个点。
分歧很少时 bootstrap 不可靠。b = 0、c = 4 时,重抽永远抽不出"旧过新挂",区间约为 [+1, +8],看起来"显著",可精确检验 p = 0.125。b + c 很小时以精确检验为准。
为什么配对更灵敏
用例之间难度差别很大:难的用例两个版本都挂,简单的两个版本都过。不配对的检验把"用例难度"的波动也算进了噪声;配对比较让每条用例和自己比,难度在相减时抵消。
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 条用例。
再看模拟:200 条用例,旧版真实通过率 80%,真实提升 5 个百分点,分歧率 9%。重复实验,McNemar 大约 60% 的时候能检出这个提升,不配对的 z 检验只有 12% 左右。
配对的方差近似是 \((b+c)/N^2\),只和分歧多少有关;不配对的方差是 \([p_1(1-p_1) + p_2(1-p_2)]/N\),只取决于两个通过率,不管用例难度怎么分布都一样;难度造成的相关性只会让配对方差变小。两个版本在同一条用例上的结果越一致,配对的优势越大。Agent 评测里这种一致性几乎总是存在:难用例对两个版本都难。反过来,如果两个版本在同一条用例上各挂各的,分歧率高到和"两个互不相关的版本"差不多,配对就没有优势。
这是"独立性假设"那章的另一面:同一条用例的多次结果相关,会让"按独立算"的区间太窄;两个版本在同一条用例上的结果相关,反过来能让配对比较更灵敏。相关性不是坏事,关键是分析方法要和数据结构对得上。
每条用例跑多次:按用例重抽
模型输出有随机性,一条用例只跑一次,b 里可能混着 flaky 的用例。所以实际评测常常每条用例跑 m 次,每条用例得到一个通过率而不是 0/1。
这时 McNemar 不能直接用(它要求每条用例一对 0/1)。bootstrap 照样能用,但抽样单位必须是用例:先算每条用例的"新版通过率 − 旧版通过率",得到 N 个差值,再在这 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 那章的思路。
回归清单,和"换便宜模型"
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% 双侧区间的下界。成本也要一起报:单次成本从多少降到多少,换来的是多大的质量风险。
代码
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))
输出(scipy 1.8 / numpy 1.22):
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]
binomtest 需要 scipy ≥ 1.7;更老的版本用 binom_test。关键是 paired_bootstrap 里的 diff[idx]:先按用例配对相减,再整条用例抽。
常见错误说法
- “新版 86% 比旧版 82% 高,所以新 prompt 更好”:100 条用例上 +4 个点,就算一条都没弄坏(b = 0,c = 4),McNemar 精确 p 也有 0.125;弄坏的越多越不显著。
- “两个 Wilson 区间重叠,所以没有差异”:各算各的区间是不配对的做法,扔掉了配对信息,也不是正式的检验。
- “p = 0.8,所以便宜模型和 Opus 一样好”:不显著不等于没差异,要看差值区间的下界。
- “整体涨了,旧过新挂的几条是噪声”:没逐条看之前,不知道它们是 flaky 还是真回归。
- “每条用例跑 10 次,bootstrap 按 400 次运行重抽”:抽样单位必须是用例,否则区间窄得离谱。