配对 Bootstrap回答固定比较口径下,样本差值会怎样波动。在计算区间之前,还必须确认比较的到底是不是同一类人、同一种任务。若模型 A 和 B 接到的样本构成不同,总体均值的差异可能主要来自分母。

本篇用一组完全合成的查询成功率演示:A 在每个用户组中都更好,总体成功率却更低。这是可直接核验的加权平均反转;计算没有矛盾,改变的是两边使用的权重。

两张分组表怎样得到相反结论

假设“成功”已经有预先固定的二元判定规则。系统把不同流量送给两个固定模型,各收到 100 条查询。用户组在分流之前定义,这里仅作描述性比较,不假定分流是随机实验。

用户组A 成功 / 查询A 成功率B 成功 / 查询B 成功率
新用户8 / 1080%63 / 9070%
回访用户36 / 9040%3 / 1030%
总体44 / 10044%66 / 10066%

A 的每组成功率均比 B 高 10 个百分点,总体却低 22 个百分点。表中所有数值是教学构造,不是本站模型或真实用户的实验结果。

不要把最后一行写成两个组百分比的简单平均:A 的 90% 流量在成功率较低的回访组,而 B 的 90% 流量在成功率较高的新用户组。总体计算必须使用各自实际分母。

总体是条件成功率的加权平均

用 z 表示分组,r_Az=s_Az/n_Az 表示 A 在组 z 的成功率,w_Az=n_Az/Σ_z n_Az 表示该组在 A 中的查询占比。由计数恒等式:

1
2
3
R_A = Σ_z s_Az / Σ_z n_Az
= Σ_z (n_Az / Σ_j n_Aj) × (s_Az / n_Az)
= Σ_z w_Az r_Az

同理 R_B=Σ_z w_Bz r_Bz。本例的逐项计算是:

模型 新用户权重 × 率 回访权重 × 率 总体
A 0.1×0.8=0.08 0.9×0.4=0.36 0.44
B 0.9×0.7=0.63 0.1×0.3=0.03 0.66

只有当两边使用同一组非负权重时,每一组 A 都不差于 B,才必然有加权总体 A 不差于 B,因为 Σ_z q_z(r_Az−r_Bz)≥0。反转例子用的是不同的 w_A 和 w_B,不能套用这个结论。

A 的流量由一成新用户和九成回访用户构成,B 相反;改用两组各半的共同权重后,A 成功率六成、B 五成
原创权重示意图。相同颜色代表同一个用户组,两个模型的实际构成不同。查看完整图

直接标准化是在定义另一项比较

先选择目标总体的组权重 q_z,要求 q_z≥0、Σq_z=1,两边都用它:

1
2
3
R_A(q) = Σ_z q_z r_Az
R_B(q) = Σ_z q_z r_Bz
Δ(q) = R_A(q) − R_B(q) = Σ_z q_z(r_Az−r_Bz)

取新用户、回访用户各 1/2,得到 A 为 0.6、B 为 0.5,差值 +0.1。原总体差值 −0.22 仍然正确,只是回答不同问题:前者比较共同构成下的组内表现,后者描述各自实际接收流量的总体结果。

共同目标构成 q A 标准化成功率 B 标准化成功率 A−B
新用户 1/2,回访 1/2 0.60 0.50 +0.10
新用户 1/5,回访 4/5 0.48 0.38 +0.10
新用户 4/5,回访 1/5 0.72 0.62 +0.10

本例差值不随共同 q 变化,是因为两组差值恰好都为 0.1。一般数据不会有这个性质;当某些组 A 好、某些组 B 好时,目标权重会影响差值甚至方向。可以报告敏感性分析,但不能看完结果后挑一套最有利权重,当作预先定义的主结论。

把原总体差异精确拆成两部分

定义两模型平均组权重 w̄_z=(w_Az+w_Bz)/2,以及平均组成功率 r̄_z=(r_Az+r_Bz)/2。对任意一组都有:

1
2
w_A r_A − w_B r_B
= [(w_A+w_B)/2](r_A−r_B) + [(r_A+r_B)/2](w_A−w_B)

展开右侧,交叉项相消即可证明。这给出一个对称的算术分解:

1
2
3
R_A − R_B = 组内率差项 + 构成差异项
组内率差项 = Σ_z w̄_z(r_Az−r_Bz)
构成差异项 = Σ_z r̄_z(w_Az−w_Bz)
组平均权重组内率差组内项贡献平均率权重差构成项贡献
新用户0.5+0.1+0.050.75−0.8−0.60
回访用户0.5+0.1+0.050.35+0.8+0.28
合计1—+0.10—0−0.32

因此 −0.22=+0.10−0.32。这个对称恒等式不是唯一可能的分解;换一个参照权重也能得到另一种分配。这里的“组内项”“构成项”都只是算术描述,不能直接称作“模型造成的提升”和“分流造成的损失”。

七列表格使用可聚焦的横向滚动区域,手机上可以左右移动查看,不缩小正文来塞满全部列。

分层不自动等于因果调整。

若分组变量受到模型或分流影响,或者分层会引入选择偏差,直接控制它可能改变要估计的效应。需要先定义时间顺序、目标问题与因果假设,再决定是否调整。不能只因分组结论更好看就宣布它更正确。

这个区别也是 Pearl 的《Understanding Simpson’s Paradox》讨论的关键:观察表格本身不足以决定应采用边际关系还是条件关系。本文的合成表与分解只验证加权计算,不承担因果识别证明。

完整标准库实现与精确实验

计数用整数、比例用 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
import json
from dataclasses import dataclass
from fractions import Fraction


@dataclass(frozen=True)
class Cell:
success: int
total: int


def validate_table(table):
if not isinstance(table, dict) or not table:
raise ValueError("table must be a nonempty mapping")
for group, cell in table.items():
if not isinstance(group, str) or not group.strip() or not isinstance(cell, Cell):
raise ValueError("invalid group or cell")
if type(cell.success) is not int or type(cell.total) is not int:
raise ValueError("counts must be integers")
if cell.total <= 0 or not 0 <= cell.success <= cell.total:
raise ValueError("missing support or invalid counts")


def compare(a, b, target):
validate_table(a)
validate_table(b)
if not isinstance(target, dict) or a.keys() != b.keys() or a.keys() != target.keys():
raise ValueError("groups must match exactly")
q = {}
for group, weight in target.items():
if type(weight) not in (int, str, Fraction):
raise ValueError("use exact rational weights")
try:
q[group] = Fraction(weight)
except (ValueError, ZeroDivisionError):
raise ValueError("invalid rational weight") from None
if any(v < 0 for v in q.values()) or sum(q.values()) != 1:
raise ValueError("weights must be nonnegative and sum to one")
total_a = sum(cell.total for cell in a.values())
total_b = sum(cell.total for cell in b.values())
ra = {g: Fraction(c.success, c.total) for g, c in a.items()}
rb = {g: Fraction(c.success, c.total) for g, c in b.items()}
wa = {g: Fraction(c.total, total_a) for g, c in a.items()}
wb = {g: Fraction(c.total, total_b) for g, c in b.items()}
raw_a = Fraction(sum(c.success for c in a.values()), total_a)
raw_b = Fraction(sum(c.success for c in b.values()), total_b)
standard_a = sum(q[g] * ra[g] for g in a)
standard_b = sum(q[g] * rb[g] for g in a)
within = sum((wa[g]+wb[g]) * (ra[g]-rb[g]) / 2 for g in a)
composition = sum((ra[g]+rb[g]) * (wa[g]-wb[g]) / 2 for g in a)
return {"raw_a": raw_a, "raw_b": raw_b, "raw_delta": raw_a-raw_b,
"standard_a": standard_a, "standard_b": standard_b,
"standard_delta": standard_a-standard_b,
"within": within, "composition": composition}


if __name__ == "__main__":
a = {"new": Cell(8, 10), "returning": Cell(36, 90)}
b = {"new": Cell(63, 90), "returning": Cell(3, 10)}
equal = {"new": "1/2", "returning": "1/2"}
report = compare(a, b, equal)
# Keep group rates fixed while changing only the observed denominators.
changed = {"new": Cell(72, 90), "returning": Cell(4, 10)}
assert compare(changed, b, equal)["standard_delta"] == report["standard_delta"]
print(json.dumps({"original": {k: str(v) for k, v in report.items()},
"same_mix_raw_delta": str(compare(changed, b, equal)["raw_delta"])},
ensure_ascii=False, indent=2))

实际输出 raw_delta=−11/50、standard_delta=1/10、within=1/10、composition=−8/25。只把 A 的查询构成变成与 B 一样的九成新用户、一成回访用户,保持两组成功率不变,原总体差值就变为 +1/10,标准化差值保持不变。

代码有意拒绝浮点权重,使用 “1/2” 或 Fraction(1,2) 明确给出精确比例;缺组、空组和错误分母也直接拒绝。即使某组目标权重是零,这份同时计算原总体和分解的实现也要求该组两边都有合法分母。真正需要合并组或删组时,应先重新定义比较口径。

设组数为 K,计算使用 O(K) 次有理数运算,存储 O(K)。Fraction 的实际成本还随整数位数增长,这不是固定机器字长下的常数时间承诺;原始 N 行数据若尚未汇总,需先进行 O(N) 的分组聚合。

数值相等不等于问题已经解决

情况 不能省略的处理
某模型在目标组没有样本 无法直接估计该组成功率;不把未知当作 0%
某组样本极少 分层率可能波动很大;标准化不是增加样本量
分组内任务难度仍不同 标准化只平衡已定义组,组内剩余差异仍在
用户重复出现 按独立用户或更合适单位处理推断,不能直接逐行重采样
目标权重也由样本估计 若目标是总体权重,需要考虑它的不确定性;本例把 q 当作固定
组是分流之后才产生 先明确因果问题;避免把中介或选择机制随意当作控制变量

还应区分两类量:原始总体率适合描述各自实际流量,标准化率适合描述明确共同构成下的比较。报告时保留分子分母、分组定义、目标权重和差值方向,读者才有机会判断结论适用到哪里。

若要继续计算区间,可以在每次合法重采样后重算各组率与 Δ(q),并保持预定的独立单位和目标权重定义;不能简单把总体 Bootstrap 区间搬到标准化差值上。本篇只做精确算术与构成反例,不报告未经验证的覆盖率。

自检:每组 A 都高于 B,是否应该删除那条“总体 A 更差”的结果?

不应该。两个结果描述不同的构成。应保留实际总体结果,说明权重差异,再报告预先定义的共同目标构成下的比较;是否能作因果解释,需要额外假设。

验证程序 tests/verify-simpson-standardization-article.cjs 运行全文代码,以原始二元行展开独立对照计数,并检查随机分组恒等式、模型交换、复制不变性、权重敏感性及非法输入。可接回配对 Bootstrap或模型回归评测;也可对照主动学习,理解模型主动选择的数据为何不宜直接代表目标总体。