NumPy 统计基础解释怎样计算均值和分位数;模型回归评测强调固定题集与逐题比较。假如新模型的平均得分高了 0.02,接下来还需要问:换一批同来源的样本,这个差值可能怎样波动?

Bootstrap 从已观察样本构造经验分布,反复有放回地抽样并重算统计量。它能帮助分析抽样不确定性,但不能自动修复偏斜题集、错误标签、相关样本或反复挑选模型造成的偏差。本篇只处理两个固定模型在配对评分上的均值差。

先定义要估计的差值

同一条样本 i,旧模型得分 a_i,新模型得分 b_i。规定越高越好,定义 d_i=b_i−a_i,观察到的均值差为:

1
Δ_hat = mean(d_i) = mean(b_i) − mean(a_i)

目标是指定总体和抽样机制下的期望差值 Δ。若得分是通过与否,Δ_hat=0.2 表示提高 20 个百分点,不是相对提高 20%。若比较的是越小越好的损失或延迟,必须另外说明方向,不能沿用“差值为正就是改进”的解释。

下面是假设的五条合成评分,不是某个真实模型的成绩:

样本 旧 a 新 b d=b−a
A 0 1 1
B 1 1 0
C 0 1 1
D 0 0 0
E 1 0 −1

旧模型均值 0.4,新模型均值 0.6,Δ_hat=0.2。这个算术没有不确定性;我们不确定的是从这五条推断到所定义总体时,样本选择带来的误差。

为什么两边必须用相同抽样索引

一次配对重采样若抽到 [A,A,C,E,B],就同时取这五条的旧、新评分:旧均值 0.4、新均值 0.8,本次 Δ*=0.4。A 出现两次是有放回抽样的正常结果,不应自行去重。

如果旧模型抽一组索引、新模型另抽一组索引,就破坏了同一样本的配对结构。这种独立两样本重采样对应另一种假设,不再保留当前样本上的协方差。差值方差满足:

1
Var(b−a) = Var(b) + Var(a) − 2 Cov(a,b)

两模型在哪些样本上一起成功或一起失败,会影响差值波动;只看两个总体通过率无法还原这种关系。SciPy bootstrap 官方文档明确说明 paired=True 会对所有输入使用同一组重采样索引。

本文先逐行计算 d,再对 d 重采样;对于均值差,这与同时抽取配对的 a、b 数学等价。这个简化不适用于所有指标:F1、相关系数或复杂排序指标通常需要重采样原始配对记录,再完整重算指标。

配对重采样同时抽取同一条记录的新旧评分;如果多行来自同一文档,改为抽取整个文档组,不能把重复行当成独立新样本
原创重采样关系图;抽样单位决定保留哪些依赖关系,不是模型效果的实测图。查看完整图

百分位区间怎样得到

把 B 次均值差排序为 z_0 到 z_(B−1)。本篇取 95% percentile 区间,即 2.5% 和 97.5% 分位数;线性插值约定如下:

1
2
3
4
h = (B−1)q
j = floor(h), k = ceil(h), t = h−j
Q(q) = (1−t)z_j + t z_k
CI = [Q(0.025), Q(0.975)]

明确插值方法很重要,尤其在离散小样本中,换一种分位数约定可能得到不同端点。Bootstrap 标准误则可用 B 个重采样统计量的样本标准差估计;它不是原始评分的标准差。

五条数据一共只有 5^5=3125 种等概率有序重采样,可以全部枚举,免去本例的 Monte Carlo 抽样误差。精确得到经验重采样分布的均值为 0.2、总体标准差约为 0.334664,95% 百分位区间为 [−0.4,0.8]。它仍然只是经验分布下的计算,不是总体分布已被精确知道。

还能独立验证这个标准差:d 的经验方差(分母为 5)为 0.56;从该经验分布独立抽五次的均值方差为 0.56/5=0.112,开平方就是 0.334664。这里计算的是完整经验分布的总体标准差,和有限 B 次抽样使用分母 B−1 的标准误估计要分清。

区间包含零,能够说明什么

本例的区间跨过零,不能据此宣称总体平均差值已可靠为正。也不能反向宣称两个模型完全等效:区间很宽,可能同时容纳有实际意义的正、负差值。

置信区间是一种重复抽样程序的覆盖性质,依赖适用条件与近似精度;不能把已经算出的这一个区间解释为“参数有 95% 概率在里面”。在五条这样的小样本、离散统计和边界情形下,名义 95% 与实际覆盖率可能差很大。

本篇选择 percentile 是为了把流程手算清楚,不宣称它优于 basic 或 BCa。SciPy 文档提供三种方法,默认使用 BCa;BCa 也可能在退化分布中出现无法计算的端点。方法名称不能替代对数据生成和统计量的检查。

一百行不一定是一百个独立样本

把 A 到 E 每条记录机械复制 20 次,得到 100 行。信息并没有增加;如果把每一行都独立抽样,经验均值的重采样标准差会缩小到约 0.074833,恰好是原先的 1/√20。区间可能因此错误地看起来已经支持正差值。

若保留五个原始组,按组抽样,每次抽五个组、选中的组内 20 行一起保留,均值差分布就与原五条配对数据相同。这个反例说明:复制记录不能创造独立证据。

数据结构 本篇建议先识别的抽样单位
随机独立抽取的题目 一条完整配对题目
同文档生成的多道题 文档组,保留组内所有配对题目
同一用户的多次交互 用户组,前提是组之间适合视为独立
连续时间观测 可能需要时间块或其他依赖模型,不能直接套普通行抽样

下面代码允许指定 cluster,按组有放回抽相同数量的组。统计目标仍为行加权均值差:每次用抽中组的全部差值之和,除以抽中行数。组大小不等时,这不是每组均值的等权平均,重采样行数也不必等于原始行数。它假设组是抽样单位,并不自动适用于固定分层设计、任意时间依赖或只有极少几个组的可靠推断。

完整标准库实验

Python 3,无第三方依赖。每行显式保存唯一 key、旧新评分及 cluster。若逐题独立抽样,就给每条题目不同的 cluster。代码能检查 ID 重复和数值格式,不能判断你是否把两模型的评分按正确题目配在一起;输入前仍须核对 ID 与版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
from dataclasses import dataclass, replace
from itertools import product
import json
import math
import random
import statistics


@dataclass(frozen=True)
class Pair:
key: str
before: float
after: float
cluster: str


def finite(x):
if type(x) not in (int, float):
return False
try:
return math.isfinite(x)
except OverflowError:
return False


def prepare(rows):
if not isinstance(rows, (list, tuple)) or len(rows) < 2:
raise ValueError("at least two paired rows required")
seen, groups, differences = set(), {}, []
for row in rows:
if not isinstance(row, Pair):
raise ValueError("expected Pair records")
if not isinstance(row.key, str) or not row.key or row.key in seen:
raise ValueError("row keys must be nonempty and unique")
if not isinstance(row.cluster, str) or not row.cluster:
raise ValueError("cluster must be a nonempty string")
if not finite(row.before) or not finite(row.after):
raise ValueError("scores must be finite real numbers")
difference = row.after - row.before
if not finite(difference):
raise ValueError("difference overflowed")
seen.add(row.key)
differences.append(difference)
groups.setdefault(row.cluster, []).append(difference)
if len(groups) < 2:
raise ValueError("at least two sampling units required")
totals = [(math.fsum(values), len(values)) for values in groups.values()]
return math.fsum(differences) / len(rows), totals


def resamples(rows, draws=5000, seed=64893):
if type(draws) is not int or draws < 2:
raise ValueError("draws must be an integer greater than one")
if type(seed) is not int:
raise ValueError("seed must be an integer")
estimate, totals = prepare(rows)
rng = random.Random(seed)
values = []
for _ in range(draws):
selected = [totals[rng.randrange(len(totals))] for _ in totals]
value = math.fsum(total for total, _ in selected) / sum(n for _, n in selected)
if not finite(value):
raise ValueError("resample overflowed")
values.append(value)
return estimate, values, len(totals)


def quantile_sorted(values, q):
if not finite(q) or not 0 <= q <= 1 or not values:
raise ValueError("invalid quantile request")
h = (len(values) - 1) * q
j, k = math.floor(h), math.ceil(h)
t = h - j
return (1 - t) * values[j] + t * values[k]


def bootstrap(rows, draws=5000, seed=64893, confidence=0.95):
if not finite(confidence) or not 0 < confidence < 1:
raise ValueError("confidence must be in (0,1)")
estimate, values, units = resamples(rows, draws, seed)
ordered = sorted(values)
alpha = (1 - confidence) / 2
return {"delta": estimate,
"ci": [quantile_sorted(ordered, alpha),
quantile_sorted(ordered, 1 - alpha)],
"se": statistics.stdev(values),
"rows": len(rows), "units": units, "draws": draws}


if __name__ == "__main__":
old, new = [0, 1, 0, 0, 1], [1, 1, 1, 0, 0]
rows = [Pair(str(i), a, b, str(i)) for i, (a, b) in enumerate(zip(old, new))]
repeated = [replace(row, key=f"{row.key}-{j}") for row in rows for j in range(20)]
naive = [replace(row, cluster=row.key) for row in repeated]
d = [b - a for a, b in zip(old, new)]
exact = sorted(math.fsum(d[i] for i in indices) / 5
for indices in product(range(5), repeat=5))
print(json.dumps({
"paired_five": bootstrap(rows),
"naive_hundred_rows": bootstrap(naive),
"clustered_hundred_rows": bootstrap(repeated),
"exact_empirical": {"resamples": len(exact),
"mean": statistics.mean(exact),
"sd": statistics.pstdev(exact),
"ci": [quantile_sorted(exact, .025),
quantile_sorted(exact, .975)]}}, indent=2))

输出中,原五条与成组重复后的结果使用相同随机种子,会有相同的区间和标准误;naive_hundred_rows 错把重复记录当成独立单位,区间会窄得多。exact_empirical 给出前面的 3125 种精确结果。固定种子便于复现计算,不会使样本变得更有代表性。

设 N 为行数、G 为组数、B 为重采样次数。代码先汇总组内总分与大小,每次只抽 G 个组,总时间 O(N+BG+B log B),末项来自区间计算的排序;内存 O(N+B+G)。逐行独立时 G=N。完整枚举仅用于五条教学数据,复杂度随 N^N 增长,不能用于大数据。

从区间到决策还缺哪些检查

风险 需要另行处理的事情
数据分布偏斜 明确目标总体、抽样框与关键分组,不靠增大 B 修复
标签不可靠 先核对标注一致性与裁决,重采样不会自动识别错误标签
固定训练出的模型 本文不包含训练随机种子造成的波动
训练或调参复用测试集 固定最终评测口径,避免反复选择后的乐观偏差
小样本、少数组 区间可能离散且覆盖不佳,不能把两组也能运行当成推断可靠
所有观察差值相同 区间会退化;不代表总体误差必然为零
多个切片反复寻找正结果 需要预先计划与相应的多重比较处理
不含零的区间 仍要检查实际意义、关键题退步和依赖结构

tests/verify-paired-bootstrap-article.cjs 运行正文代码,再用多项式频数计数独立重建精确经验分布,核对配对关系、不同组大小、重复记录、分位数插值和随机数可复现性。它验证计算,不宣称小样本 percentile 区间已经具有准确的 95% 覆盖率。

自检:把五条数据复制二十遍,为什么不能因此获得一百条独立证据?

复制没有引入新的独立观察。逐行抽样会人为缩小波动;保留原始五个组并成组抽样,才能在这个例子中保留原来的经验不确定性。实际数据还需要根据生成过程判断独立单位。

先用NumPy 统计核对均值与分位数,再用本文理解差值的不确定性,最后接回回归评测与发布门槛。如果比较的是BatchNorm 等训练改动,还要把训练设置、随机种子和测试集来源一起写清楚。