配对 Bootstrap通过重采样已观察到的配对记录分析差值的不确定性。置换检验则先规定零假设下允许的数据变换,再问:在这套参考机制中,当前统计量有多极端?

“有配对”不是任意打乱标签的通行证。本篇讨论保持每一对对应关系、在对内交换两边身份的检验;对均值差而言,它等价于翻转差值的符号。只有零假设和实验设计支持这种变换时,枚举结果才有检验解释。

先说清楚什么能够交换

设同一评测单位 i 有旧分数 a_i、新分数 b_i,差值 d_i=b_i−a_i,越高越好。统计量为 T_obs=Σd_i/N。

本文的参考假设是:在零假设下,差值向量的分布对于允许的独立符号翻转保持不变。它可能来自匹配对内的随机分配设计和相应的尖锐零假设,也可能作为观测数据的对称性假设提出;后者必须另行论证。

条件 能推出什么 不能自动推出什么
记录能按 ID 配对 确认同一单位两种分数的对应 两种身份在零假设下可交换
E[d]=0 定义一个均值零假设 差值分布关于零对称
对内随机分配且满足相应设计假设 可依据分配机制构造随机化分布 所有观测研究都能照搬这个机制
多行来自同一用户或文档 可能需要整组交换 每一行都能独立翻转

例如差值以 1/3 概率为 +2、2/3 概率为 −1,均值为零,却不对称。随意翻转符号会制造原分布不支持的 −2、+1;不能仅凭均值为零就保证本篇符号检验机制的有效性。

两个固定模型在同一批题上的分数也不天然满足身份可交换。计算出来的数值可以是敏感性分析;要作正式推断,仍需交代样本来源和零假设,而不是把“评测模型不同”当作随机分配。

为什么是对内交换而不是重新配对

若交换一对的 a_i、b_i,差值变为 −d_i。对 G 个独立可交换单位,各选一个符号 s_g∈{−1,+1}。逐行独立时 G=N:

1
T* = (1/N) Σ_i s_i d_i

保留原来的 a_i,却随机改变 b 的配对次序,会检验另一种关系;把所有旧、新分数混在一起重新分组,也对应不同机制。SciPy 官方 permutation_test 文档区分 samples、pairings、independent 三种变换,本篇对应 samples 的双样本配对交换思想。

五条评分怎样得到 p 等于八分之一

下面是合成评分,不是真实模型成绩。新模型平均分更高,但样本量很小。

ID 旧 a 新 b 差值 d
A 0 1 +1
B 0 1 +1
C 0 1 +1
D 1 1 0
E 0 1 +1

T_obs=4/5=0.8。五个单位有 2^5=32 种等权符号分配。D 的正负号不改变值,但它的两种分配仍占概率质量,不能只对去重后的统计量列表取平均。

本文预先采用以绝对值定义的双侧尾部,包含等号:

1
p_exact = #{分配 : |T*| ≥ |T_obs|} / 2^G

在所声明的可交换轨道上,观察分配与参考分配同权。按统计量极端性排序后,尾部比例不超过 α 的分配最多占 α,因此拒绝概率不超过 α;并列值可能使检验保守。这个有限计数理由依赖可交换性,不能用精确枚举弥补错误的变换机制。

这个定义对正负方向同等处理。双侧 p 值还有其他约定,不能在看到结果后切换。对当前关于零对称的参考分布,绝对值尾部与截断到 1 的“双倍较小单侧尾部”一致;并非所有非对称统计量都如此。

四个非零单位中正号数T*含零差单位的分配数双侧尾部
0−0.82是
1−0.48否
2012否
3+0.48否
4+0.82是

因此 p=(2+2)/32=1/8=0.125。它不是“零假设为真的概率是 12.5%”,也不是“改进只有 12.5% 的概率成立”。在所声明的零假设机制下,参考分配中有八分之一至少同样极端。

五个配对单位各自选择保留或交换,三十二种分配归入五种统计量;两端尾部共四种,精确 p 值为八分之一
原创符号分配图。柱高是分配数量,不是五个统计量等概率。查看完整图

数据重复不应制造更小的 p 值

把五条原记录各复制二十次,得到 100 行。如果这些复制仍来自原来的五个独立单位,就只能按五组整体翻转。组差值总和为 [20,20,20,0,20],统计量仍除以 100,得到同样的 0.8、同样的 p=1/8。

若错误地把 100 行当作独立单位,会出现 80 个同方向的非零差值;其双侧极端分配比例是 2/2^80=2^−79。这个极小值来自错误独立性假设,没有带来新信息。

对于大小不等的真实组,令 D_g=Σ_{i∈g}d_i,则使用:

1
T* = (1/N) Σ_g s_g D_g

这样保留了按行加权的原均值差,而不是把每个组改成等权均值。是否应该按行、按用户还是按文档等权,是要预先确定的估计目标;“按组交换”与“改成每组等权”是两个动作。

Monte Carlo 为什么不能直接报告零

G 大时无法完整枚举,可以按参考机制独立随机生成 B 个符号向量。若其中 b 个至少同样极端,本篇采用:

1
p_MC = (b+1)/(B+1)

这把观察到的分配纳入计数约定,避免“抽样中没碰到尾部就宣称 p=0”。它仍有 Monte Carlo 波动,不是 p_exact 的恒等替代;独立采样和交换性等适用条件仍需满足。SciPy 文档也说明了随机置换的加一约定,详细背景可查 Phipson 与 Smyth 的作者预印本。

完整枚举时,观察分配本来就在分配集合中,直接计算精确比例,不额外再加一。固定随机种子便于复现模拟,不使零假设变得正确,也不能事后反复换种子挑最小值。

完整标准库实验

为了准确处理尾部的等号,下面限定整数评分,使用整数和比较极端性,最后才转成 Fraction。它不处理任意浮点评分的容差选择,也不自动判断可交换性。

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
import itertools
import json
import random
from dataclasses import dataclass, replace
from fractions import Fraction


@dataclass(frozen=True)
class Pair:
key: str
old: int
new: int
unit: str


def prepare(rows):
rows = tuple(rows)
if not rows:
raise ValueError("empty data")
seen, grouped = set(), {}
for row in rows:
if not isinstance(row, Pair):
raise ValueError("expected Pair records")
if any(not isinstance(v, str) or not v.strip() for v in (row.key, row.unit)):
raise ValueError("IDs must be nonempty strings")
if row.key in seen or type(row.old) is not int or type(row.new) is not int:
raise ValueError("duplicate ID or non-integer score")
seen.add(row.key)
grouped[row.unit] = grouped.get(row.unit, 0) + row.new-row.old
return [grouped[g] for g in sorted(grouped)], len(rows)


def paired_test(rows, draws=None, seed=0):
totals, n = prepare(rows)
if type(seed) is not int:
raise ValueError("seed must be an integer")
observed = sum(totals)
groups = len(totals)
if draws is None:
if groups > 16:
raise ValueError("exact enumeration limited to 16 units")
signs = itertools.product((-1, 1), repeat=groups)
assignments = 2**groups
else:
if type(draws) is not int or draws < 1:
raise ValueError("draws must be a positive integer")
rng = random.Random(seed)
signs = ([1 if rng.getrandbits(1) else -1 for _ in totals]
for _ in range(draws))
assignments = draws
extreme = sum(abs(sum(s*d for s, d in zip(sign, totals))) >= abs(observed)
for sign in signs)
pvalue = (Fraction(extreme, assignments) if draws is None else
Fraction(extreme+1, assignments+1))
return {"mean_difference": str(Fraction(observed, n)), "pvalue": str(pvalue),
"units": groups, "nonzero_units": sum(d != 0 for d in totals),
"assignments": assignments, "extreme": extreme,
"mode": "exact" if draws is None else "monte-carlo"}


if __name__ == "__main__":
differences = [1, 1, 1, 0, 1]
rows = [Pair(str(i), 0, d, str(i)) for i, d in enumerate(differences)]
repeated = [replace(row, key=f"{row.key}:{j}") for row in rows for j in range(20)]
naive = [replace(row, unit=row.key) for row in repeated]
print(json.dumps({"five_pairs": paired_test(rows),
"hundred_rows_five_units": paired_test(repeated),
"monte_carlo": paired_test(rows, draws=9999, seed=65087),
"wrong_independence": paired_test(naive, draws=9999, seed=65087)},
ensure_ascii=False, indent=2))

五条与成组重复的精确结果都为 mean_difference=4/5、pvalue=1/8、assignments=32、extreme=4。Monte Carlo 结果按打印出的 extreme 和 10000 的分母解释;错误逐行独立的输出即使很小,也不是实际证据增强。

主程序把 D 的旧、新分数都设为零,与表格中都为 1 具有相同差值;这个均值差检验只使用 d,零点平移不影响计算。对于其他统计量,不能未经检查就用差值替代原始两边数据。

设行数 N、独立单位数 G。准备阶段 O(N+G log G),枚举 O(G2^G)、随机模拟 O(BG),额外存储 O(N+G);代码逐个消费符号向量,不保存全部分配。整数运算成本还随数值位数增长,精确模式限制 G≤16 是教学保护,不是大数据实现。

结果解读与停止边界

情况 应如何报告
所有组差值总和均为零 本统计量的参考分布只有零,双侧 p=1;不等于模型逐条完全相同
只有少数非零独立单位 精确 p 值很离散;四个同向单位的双侧值也只能到 1/8
p 值较大 没有足够证据拒绝相应零假设;不能证明两个模型等效
p 值较小 还要看效应大小、数据质量和适用假设,不等于业务收益很大
多个模型、多个指标反复试 预先定义主要比较并处理多重检验,不能只挑最小 p
每来一条数据就重复检验 固定样本检验的阈值不自动支持任意时刻停止
自检:把每条记录复制二十次,差值均值不变,p 值是否应该自动更小?

不应该。复制没有增加独立信息;如果原来只有五个独立单位,仍按五组整体交换。本例精确 p 值保持 1/8,逐行独立翻转得到的小值来自错误假设。

验证程序 tests/verify-paired-permutation-article.cjs 用对内交换原始评分和多项式频数参考核对精确结果,并测试成组重排、复制不变性、零差值、Monte Carlo 计数和非法输入。与分层标准化一起使用时,必须先固定目标总体和统计量;本篇按行均值的 p 值不能直接移给另一套权重。