成对bootstrap:同题评测为何共用抽样索引
A、B回答同一批评测题,做bootstrap比较时,应让两者在每次重抽样中使用同一组题目索引。分别抽取A和B,会打散“同一道题”的关系,改变差值的波动;即使两者每题得分完全相同,也能抽出非零差值。
文章目录
这里用四道虚构题和精确有理数,把成对与独立重抽样的结果逐一算清楚。重点是抽样单位与索引关系,不输出真实模型排名、显著性结论或上线建议。Read in English。
先配对题目,再讨论抽样
假设同一题i上的二元评分为aᵢ、bᵢ,1表示成功、0表示失败。测试集有n题,原始准确率差值为Δ=Σ(aᵢ−bᵢ)/n。这个描述性数值无需重抽样就能算出;bootstrap考察的是在指定重抽样方案下,这个统计量如何变化。
在一轮成对bootstrap中,从0到n−1有放回地抽n个索引J,然后同时计算A[J]和B[J]。同一题可以重复出现,也可能没有出现,但每次出现时携带A、B两项得分。SciPy 1.16.0的bootstrap文档中,paired=True明确采用共享索引;paired=False则分别重抽样。本文只借此核对接口语义,代码没有调用SciPy。
“共享”不只是两次随机数生成器碰巧设置同一个种子。应把索引序列作为一项数据显式生成,再应用到所有成对字段。额外的随机调用、不同长度或不同过滤条件,都可能使“看起来同种子”的流程不再对应同一批题。
每题完全相同,独立抽样仍会造出差值
令A=B=(1,1,0,0)。两者原始准确率都是1/2,且不只是总分相等,而是每道题都一样。共享任何索引序列,A与B的重抽样均值必然相同,所以差值始终为0。
若分别抽取,A可以抽到索引(0,0,0,0),得到均值1;B可以抽到(2,2,2,2),得到均值0,于是差值为1。这不是观察到模型表现分化,而是给两者分配了不同题目组成。独立样本设计未必错误,但它已经不是这里要保留的同题比较。
A均值=1/2
B均值=1/2
差值=0
A索引=(0,0,0,0)
B索引=(2,2,2,2)
差值=1
四题有放回抽四次,共有4⁴=256个等可能的有序索引序列。成对方案对这256种序列各算一个差值;独立方案有256²=65,536种序列配对。下面的程序先统计均值频数,再用频数乘积精确计数独立组合,不需要依赖随机种子或增加模拟轮数。
协方差告诉你,错误不一定总往一个方向
对固定的这四行数据,定义经验方差Var_emp(A)=Σ(aᵢ−ā)²/n,经验协方差Cov_emp(A,B)=Σ(aᵢ−ā)(bᵢ−b̄)/n。注意分母是n,不是无偏样本方差使用的n−1。
每一轮抽n个独立索引、并在A/B之间共享索引时,均值差的条件重抽样方差为[Var_emp(A)+Var_emp(B)−2Cov_emp(A,B)]/n。分别抽样时,共享项消失,变为[Var_emp(A)+Var_emp(B)]/n。这是对经验分布的推导,不是总体真差值已被精确估计。
逐题相同的例子中,两项经验方差与协方差都是1/4。成对结果为0,独立结果为1/8。再把B换成(0,0,1,1),与A逐题互补,协方差变成−1/4;成对方差为1/4,独立仍为1/8。因此,拆散配对既可能夸大波动,也可能低估波动,不能只记成“独立抽样比较保守”。
这两个构造中,A和B的总成功次数始终各为2,边际准确率都为1/2;但同题对应关系不同,成对差值方差便从0变成1/4。只有两个总分时,无法还原这项差别。这也是评测导出要保存题目ID和两列逐题得分,而不只保存平均数的原因。
二十项精确检查
代码使用Python标准库,在本地Python 3.15.0上运行。所有均值与方差都用Fraction计算,没有Monte Carlo误差;函数仅接受二元评分,且最多六行,避免把指数枚举误用到真实大评测集。
from collections import Counter
from fractions import Fraction as F
from itertools import product
def validate(a, b):
if not a or len(a) != len(b):
raise ValueError('nonempty, equally sized aligned inputs required')
if len(a) > 6:
raise ValueError('exact teaching enumerator is limited to six rows')
if any(x not in (0, 1) for x in (*a, *b)):
raise ValueError('this example accepts binary scores only')
def sample_means(values):
n = len(values)
return Counter(F(sum(values[i] for i in idx), n)
for idx in product(range(n), repeat=n))
def paired_dist(a, b):
validate(a, b)
n = len(a)
return Counter(F(sum(a[i] - b[i] for i in idx), n)
for idx in product(range(n), repeat=n))
def independent_dist(a, b):
validate(a, b)
out = Counter()
means_a, means_b = sample_means(a), sample_means(b)
for ma, ca in means_a.items():
for mb, cb in means_b.items():
out[ma - mb] += ca * cb
return out
def moments(dist):
total = sum(dist.values())
mean = sum((x * count for x, count in dist.items()), F(0)) / total
var = sum(((x - mean) ** 2 * count for x, count in dist.items()), F(0)) / total
return mean, var
checks = 0
def check(condition):
global checks
assert condition
checks += 1
a = (1, 1, 0, 0)
p = paired_dist(a, a)
u = independent_dist(a, a)
check(p == Counter({F(0): 256}))
check(sum(u.values()) == 65536)
check(moments(p) == (0, 0))
check(moments(u) == (0, F(1, 8)))
check(u[F(1)] == 256 and u[F(-1)] == 256)
check(u[F(0)] == 17920)
check(u == Counter({-x: count for x, count in u.items()}))
b = (0, 1, 0, 0)
d = paired_dist(a, b)
expected = {F(0): 81, F(1, 4): 108, F(1, 2): 54,
F(3, 4): 12, F(1): 1}
check(d == Counter(expected))
check(sum(d.values()) == 256)
check(moments(d) == (F(1, 4), F(3, 64)))
check(F(d[F(0)], 256) == F(81, 256))
check(F(sum(c for x, c in d.items() if x > 0), 256) == F(175, 256))
check(paired_dist(b, a) == Counter({-x: c for x, c in d.items()}))
check(paired_dist(tuple(a[i] for i in (2, 0, 3, 1)),
tuple(b[i] for i in (2, 0, 3, 1))) == d)
anti = tuple(1 - x for x in a)
check(moments(paired_dist(a, anti)) == (0, F(1, 4)))
check(moments(independent_dist(a, anti)) == (0, F(1, 8)))
for bad_a, bad_b in [((), ()), ((1,), (1, 0)), ((2,), (0,)),
((1,) * 7, (0,) * 7)]:
try:
paired_dist(bad_a, bad_b)
except ValueError:
check(True)
else:
raise AssertionError('expected ValueError')
print(f'{checks} exact-resampling checks passed')
print('identical paired variance:', moments(p)[1])
print('identical independent variance:', moments(u)[1])
print('one-row improvement counts:', sorted(d.items()))
print('positive fraction:', F(175, 256))
实际输出为“20 exact-resampling checks passed”。程序核对逐题相同、逐题互补和一题改进三种构造,还检查交换A/B后的差值符号,以及同时重排两列后分布不变。空输入、长度不同、非二元评分和超出示例规模的输入会被拒绝。
这里的精确指对指定经验重抽样空间精确计数,不是对真实用户分布、未来模型成绩或某个置信区间的覆盖率作保证。枚举适合做小规模参考实现与回归检查;真实评测通常需要另一种计算规模,不能直接运行nⁿ枚举。
逐题相同构造里的零方差也只属于已观察行的经验重抽样。它不证明两个真实模型对所有未来输入都相同;如果未出现的新题会让两者分化,重复抽取旧行无法发现这种差异。精确枚举解决计算误差,并没有消除评测覆盖不足。
只改好一题,正差值比例也不是胜率
保留A=(1,1,0,0),改用B=(0,1,0,0)。原始差值为1/4;逐题差值为(1,0,0,0)。每轮抽四题,唯一有差异的第0题可能出现0至4次,因此重抽样差值只能取0、1/4、1/2、3/4、1。
对应频数依次为81、108、54、12、1,总和256。完全没抽到第0题的概率是(3/4)⁴=81/256;正差值比例为175/256,约68.36%。这些数值能从代码直接复现。
不能把68.36%写成“A在真实世界更好的概率”,也不能直接称为某个零假设下的p值。这里没有构造零假设分布,没有选择置信区间方法,也没有处理反复挑选方案后的选择影响。四题只是展示索引如何控制差值的教学例子,不是建议用四题评估一个模型。
题目ID与抽样单位决定方法有没有意义
长度相同不代表两列已正确对齐。正式评测应先用稳定题目ID匹配A/B,再处理缺失、超时和评分失败;不能分别删除各自失败行后直接按数组下标配对。否则共享索引保留的只是位置,不是同一道题。
若多道题来自同一文档或会话,还要判断题目是否适合作为独立抽样单位。共同上下文可能使行间相关,简单逐题bootstrap无法自动表达这种依赖。需要分组设计时,要先定义如何抽文档或会话、如何给组加权,再选择对应方法。
同一模型每题生成多次,也不能只把所有回答摊平当作更多独立题目。题目覆盖与生成随机性是不同的不确定性来源。本文固定每题一次二元评分,没有估计后者;若要研究它,需要保留重复运行结构,而不是仅更换一个paired参数。
把差值计算与推断报告分开
均值指标可以直接对逐题差值重抽样。若统计量换成整体F1等非线性指标,应在抽中的数据上重新计算目标指标,而不是默认“平均逐题F1”等于整体F1。抽样策略与统计量都要与原来的评测定义一致。
已有的LLM评测与Wilson区间说明了单个成功率的区间与逐题变化记录。本篇补上的是两方案差值的重抽样机制:先保存正确配对,再决定要报告什么不确定性。成对bootstrap不能修复有偏题集、错误评分或测试集泄漏。
本文依据SciPy 1.16.0版本文档核对共享索引语义;两幅图、三个四题构造、方差推导和二十项检查均为原创教学内容。没有运行模型或SciPy,没有输出置信区间。文章保留2026年10月23日19:00(北京时间)日期,实际于2026年10月26日补发。
支持
如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。
二维码可点击放大。更多支持方式见支持页面。


