成对bootstrap:同题评测为何共用抽样索引

黎 浩然/ 23 10 月, 2026/ 教程/TUTORIAL, 计算机/COMPUTER/ 0 comments

A、B回答同一批评测题,做bootstrap比较时,应让两者在每次重抽样中使用同一组题目索引。分别抽取A和B,会打散“同一道题”的关系,改变差值的波动;即使两者每题得分完全相同,也能抽出非零差值。

文章目录
  1. 先配对题目,再讨论抽样
  2. 每题完全相同,独立抽样仍会造出差值
  3. 协方差告诉你,错误不一定总往一个方向
  4. 二十项精确检查
  5. 只改好一题,正差值比例也不是胜率
  6. 题目ID与抽样单位决定方法有没有意义
  7. 把差值计算与推断报告分开

这里用四道虚构题和精确有理数,把成对与独立重抽样的结果逐一算清楚。重点是抽样单位与索引关系,不输出真实模型排名、显著性结论或上线建议。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。这不是观察到模型表现分化,而是给两者分配了不同题目组成。独立样本设计未必错误,但它已经不是这里要保留的同题比较。

共享J=(0,0,2,3)
A均值=1/2
B均值=1/2
差值=0
分别抽样
A索引=(0,0,0,0)
B索引=(2,2,2,2)
差值=1
原创索引图:两列原始得分都为(1,1,0,0),索引从0开始;右侧改变的是题目组成,不是模型得分。

四题有放回抽四次,共有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。因此,拆散配对既可能夸大波动,也可能低估波动,不能只记成“独立抽样比较保守”。

逐题相同:成对 — 0

逐题相同:独立 — 1/8

逐题互补:成对 — 1/4

逐题互补:独立 — 1/8

原创精确枚举图:纵向各行给出准确率差值的条件重抽样方差,横条以1/4为满宽,不是模型性能条形图。

这两个构造中,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日补发。

支持

如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。

微信支持二维码,点击查看大图
微信
支付宝支持二维码,点击查看大图
支付宝
Buy Me a Coffee,点击支持本站
Buy Me a Coffee

二维码可点击放大。更多支持方式见支持页面。

Share this Post

Leave a Comment

您的邮箱地址不会被公开。 必填项已用 * 标注

*
*