Comparison map

对比敏感性

13 分钟阅读View as markdown ↗

本页记录对比图谱背后的两项蒙特卡洛稳定性检验:在冻结分项得分的前提下,在整片权重空间上重新采样各轴权重;以及在已发布的权重下,把每一个分项得分扰动至多一分。它保存着设计与随机种子、全部二十二个产品的排名区间、一对一对比中的次序反转统计、一条被撤回的主张,以及一份能复现这份记录的脚本。

评分方法页的第一个版本只检验了恰好一种替代权重,并报告了一次交叉。同行评审做了我们本该做的事:在整片权重空间上、以及在分项得分的不确定性上跑一次蒙特卡洛。我们采纳了这套设计,并用自己的代码重跑了一遍,而不是引用评审的数字。本页发布的是我们自己的结果;完整的运行记录,以及与评审数字的一致性核对,都在 review/index/SCORES.md,而这些检验所依据的每一个分项得分都在得分页上。

两项检验

每项检验在每条轴上跑 N = 200,000 次采样:

  1. 采样权重,冻结分数。在每条轴的权重单纯形上均匀抽取非负权重(归一化的 Exp(1) 抽样);每一个已发布的分项得分保持固定。它问的是:这个排序里有多少是我们的加权判断?
  2. 冻结权重,扰动分数。保留已发布的权重;给每个产品的每一个分项得分加上独立的均匀 ±1 噪声,并裁剪到 0–10。它问的是:有多少东西取决于那些整数判断恰好正确?

这里的排名是 1 加上得分严格更高的产品数量。主运行:种子 20260807。独立重新实现(不同的代码和随机数生成器,种子 271828)。2026-08-07 在 Obscura 被评分时于 22 个产品的场上重算:排名是相对于整个场的,所以下面的区间是把 Obscura 那一行放进去重跑的,而不是留着不管,而 review/index/SCORES.md 里的工作记录把 21 个产品的运行保留下来,作为复现评审结果的记录。同一天在 URnetwork 因默认开启裁定而被重新评分时再次重算(见评分方法的台账):下面的表是重新评分之后的记录,在两套实现和两个种子下都重跑过,而重新评分之前的运行留在工作记录的运行记录表里。在 22 个产品的场上,两套实现在每一个区间和中位数上都一致,只有权重检验里的五个格子例外,它们出现在几乎并列的相邻对上(点估计分数相差 0.05),已在工作记录中注明;同样这五个格子在重新评分前后都存在差异。每一个得分噪声区间,也就是总览页所发布的那一类,在两次运行下完全相同,URnetwork 和 Obscura 的整行也是如此。

URnetwork 的结果

Obscura 的加入挪动了 X 排名的统计量:权重中位数 3→4,噪声区间 2–3 → 2–4。默认开启那次重新评分又挪动了它们:权重中位数 4→3,权重区间 2–5 → 1–4。两次都没有挪动任何噪声检验的排名统计量,也没有挪动任何 Y 统计量。

统计量检验 1,采样权重检验 2,±1 得分噪声
X 排名,中位数(第 5–95 百分位)3 (1–4)3 (2–4)
X 分数,第 5–95 百分位(权重在变)6.793–7.813
在 X 上领先 Mullvad74.0%(精确为 71/96)99.9%
在 X 上领先 Obscura100%(退化;见下)93.9%
在 X 上领先 NymVPN67.0%(精确为 343/512)30.6%
Y 排名,中位数(第 5–95 百分位)3 (1–15)7 (4–11)
Y 分数,第 5–95 百分位(权重在变)5.747–6.653
Y 低于 5.00从不,由构造决定(见那条被撤回的主张)200,000 次中 0 次(在这个噪声模型下约 4.4σ:罕见,但并非不可能)

这两条轴承载的置信度并不相等。在采样到的每一种权重下,URnetwork 的 X 排名都停留在 1–4 之内:在 X 轴的顶端附近,是你做出哪种判断决定了次序,但这条指数本身相对而言是权重稳定的。它的 Y 排名在同一片权重空间里跑遍 1–15,而且即便在已发布的权重下,±1 噪声的中位数也是第 7,而不是它的点估计分数所暗示的并列第 5。Y 指数对权重的敏感程度远高于 X,而地图上的每一个 Y 位置都应当以相应更低的置信度来读。

那些一对一对比

Mullvad。那两个 Mullvad 数字仍然是关于这张图所能给出的最有信息量的一句话,而默认开启那次重新评分改变了它们所说的内容。在那之前,URnetwork 在权重不确定性下对 Mullvad 的 X 领先是 55.55%,一次几乎完全取决于把结构(分离加端到端,合计 0.60)排在验证(0.30)之上的掷硬币。重新评分之后,分项得分差是 (+5, +3, −3, −1):URnetwork 在两个结构分项上领先,Mullvad 在验证上、以及以微弱优势在后量子上领先,而同样的权重采样给出 URnetwork 领先的比例是 74.0%(精确的单纯形体积是 71/96 ≈ 73.96%)。在已发布权重下的 ±1 得分噪声中,这个数字是 99.9%。这份领先不再是掷硬币,但残留的权重敏感性以受限的形式留存下来:一位把验证的权重定在整条轴一半左右的读者,仍然会得到 Mullvad 在前,而那仍然是一种合理的读法,不是错误。推演过的例子在下面的重新加权一节。

Obscura。这组对比随着重新评分而反转。两者如今在结构上打平(E+S 16–16,两条内容路径都是默认开启,Obscura 的那条来自构造),在 V 上也打平,所以全部 0.70 的差距都是 Obscura 所缺的后量子分项。一个纯粹的单分项差异 (0, 0, 0, +7) 让权重检验变得退化:任何给了后量子非零权重的加权都会保持这个顺序,所以"采样权重的 100%"描述的是这个差异的形状,不是它的稳健性。有信息量的检验是得分噪声,在其中 URnetwork 领先的比例是 93.9%:这超出了单个一分变化所能触及的范围,因为一个分项上一分的最大摆幅是 0.35,而差距是 0.70。重新评分之前的统计量是 69.5%,那还没有超出那个范围;在重新评分之前的分数下,差异是 (−1, 0, 0, +5),精确的单纯形份额为 5/6 = 83.33%(采样得 83.4%)。真正会让这一对拉平的,是 Obscura 发布后量子。

NymVPN。这次重新评分在另一个方向上买来了一组新的相邻关系:NymVPN 以 7.50 分,如今坐在 URnetwork 的 7.30 之上 0.20 处,比 Obscura 在它下方的距离更近。在已发布的权重下,NymVPN 领先。在采样权重上,URnetwork 领先 67.0%:差异是 (−1, −1, −1, +7),所以这个次序完全取决于后量子的权重是否超过八分之一,而精确份额是 (7/8)³ = 343/512。在得分噪声下,NymVPN 在 69.4% 的抽样中保住领先(URnetwork 领先 30.6%)。这一对被印作相邻且未有定论,正如重新评分之前的 Obscura 与 URnetwork 那样。

逐产品排名区间

第 5–95 百分位,中位数写在括号里,22 个产品的场,重新评分之后,重新实现的那次运行,种子 271828。URnetwork 和 Obscura 的两行在主运行下完全相同。这次重新评分改动了六个格子,全部在 X 权重那一列:URnetwork 2–5 (4) → 1–4 (3),Tor 1–4 → 1–5,Obscura 3–10 → 4–10,Mullvad 中位数 3 → 4,IVPN 2–8 → 3–8,ExpressVPN 2–12 → 3–12。

产品X 排名,权重X 排名,得分噪声Y 排名,权重Y 排名,得分噪声
Tor1–5 (1)1–1 (1)2–22 (19)21–22 (22)
NymVPN2–9 (3)2–3 (2)18–21 (20)20–21 (20)
URnetwork1–4 (3)2–4 (3)1–15 (3)4–11 (7)
Obscura4–10 (6)3–5 (4)9–19 (15)12–19 (16)
Mullvad1–7 (4)4–6 (5)4–12 (7)4–11 (7)
IVPN3–8 (5)5–7 (6)6–16 (11)6–13 (10)
Apple Private Relay5–15 (9)6–8 (7)13–22 (20)13–19 (17)
Windscribe7–14 (11)7–10 (8)1–5 (2)1–5 (3)
PIA7–15 (12)8–13 (10)9–15 (11)7–14 (11)
ExpressVPN3–12 (8)8–13 (10)4–10 (6)4–11 (7)
Proton VPN9–16 (14)9–15 (12)1–5 (3)1–6 (3)
NordVPN6–13 (10)9–15 (12)4–10 (6)4–11 (7)
Tailscale6–18 (13)10–16 (13)1–8 (3)1–3 (1)
Cloudflare WARP7–16 (11)11–16 (14)1–16 (5)1–6 (3)
Surfshark7–16 (12)11–17 (15)8–13 (9)5–12 (8)
Orchid9–17 (16)12–17 (15)19–22 (21)20–22 (21)
Mysterium16–18 (18)16–20 (18)7–20 (18)14–19 (17)
TunnelBear15–19 (17)16–20 (18)8–16 (13)9–16 (13)
Sentinel17–20 (20)17–20 (19)9–19 (16)15–19 (18)
IPVanish19–20 (19)17–21 (19)12–19 (16)9–16 (13)
Hotspot Shield21–21 (21)20–21 (21)8–18 (12)9–16 (13)
Hola22–22 (22)22–22 (22)6–20 (17)14–19 (17)

这些区间没有因为模型的对称性而偶然夸大任何东西。这个 ±1 噪声模型是刻意做得简单的,它不是一个经验误差分布;它的教训是:排名的置信度需要对所给出的分数本身存在不确定性,而不只是需要第二套权重。

我们复现出评审的结果了吗?

复现出来了,在评审所测量的那个 21 个产品的场上,也就是 Obscura 被评分之前。评审报告(种子 20260807,200,000 次采样,他们自己的代码):领先 Mullvad 55.64%;噪声下 X 分数 6.341–7.359,Y 分数 5.749–6.652;排名区间 2–5、1–15、2–3 和 4–11,Y 中位数为 7。我们在那个场上的运行返回了完全相同的排名区间和中位数、误差在 0.005 以内的分数上下界,以及次序反转上的 55.55%;21 个产品的记录保存在 review/index/SCORES.md,而与 Mullvad 的成对统计量在抽样误差范围内原样带进了 22 个产品的重跑。这次次序反转有一个可以用来核对所有人的精确答案:在评审所测量的那套分数下,URnetwork 减 Mullvad 的分项得分差是 (+4, +3, −3, −3),而权重单纯形中落在 URnetwork 一侧的份额算出来是 109/196 ≈ 55.61%。每一个蒙特卡洛估计,我们的和评审的,都落在与精确值相差不超过抽样误差(在这个样本量下 2σ 为 ±0.22 个百分点)的范围内。一个我们复现不出来的外部结果会是一项发现;一个我们能复现的同样是。评审的数字属于重新评分之前的记录:URnetwork 在同一天稍晚因默认开启而做的重新评分改变了它的 X 那一行,而当前精确的次序反转值是 71/96,已在上面的表里推导并采样。109/196 这项核对在重新评分之前的分数上仍然能复现,而那正是评审所测量的。

一条被撤回的主张,记录在案

直到 2026-08-07,评分方法页一直宣称 URnetwork 的 Y 位置是权重稳健的:对质量分项做任何重新加权都无法把它的 Y 压到 5.00 以下。评审的判词,"数学上为真,但在证据上是空的",被完全接受。URnetwork 的每一个 Y 分项得分都被给到 ≥ 5,所以它们的每一个凸加权平均都 ≥ 5,这是由构造决定的:我们把自己输入的一条性质当作稳定性的证据发布了出来,而无论那些输入错得多离谱,这条主张都会一直"为真"。同一条反驳在任何人提出之前,就让 X 轴上的等价主张退了场。URnetwork 的 X 分项得分同样全部 ≥ 5,所以"没有任何重新加权能把 X 压到 5.00 以下"同样是被保证的,也同样是空的。这两句话都不会再作为证据出现在本语料里。从这份记录里能说的东西都在上面的表里:在重新加权下,有信息量的输出是排名,不是越线;而对这两条线有信息量的检验是得分不确定性,在其中 URnetwork 的第 5 百分位 Y 是 5.747,那是一句关于一个简单噪声模型的陈述,不是关于世界的陈述。真正能把 Y 挪到线下的是证据:一次把延迟或吞吐评在量表中点以下的外部测量。这样的测量在任何方向上都不存在,而本语料"不捏造数字"的规则,正是那两个性能分项停在结构类别锚点 5 分上的原因。这条主张是被撤回而不是被删除,因为这份记录的价值就在于它保留自己的错误。

落在噪声之内的那些位置

单点敏感性不是蒙特卡洛,但它在边界处回答同一个问题,所以它与其余内容一起记录在这里。

  • Apple Private Relay 同时落在两条线的噪声之内,X 上 +0.10,Y 上 −0.05,独自位于两轴的交叉点,所以它应当被读作在线上,而不是任何一个象限的居民,包括它严格按符号会落进去的"学术 / 研究"格。它的那些判断性结论砍向两边:把 E 或 S 调低一分,X 会变成 4.85 或 4.75,退回线的左边;而把 S 打成 8 分,会把它明确推到右边的 5.45。正是那份敏感性,让地图选择为它加注,而不是断言它。
  • Obscura 从上方落在 Y 线的噪声之内(+0.10)。它位于 X 线右侧这件事在 +1.60 上是有把握的,而且对噪声稳健:200,000 次 ±1 噪声采样中有 0 次把它放到线的左边,第 5 百分位的 X 是 6.12。不过它对权重敏感:均匀采样的权重中有 32.6% 把它放到左边,驱动因素是 PQ 0 分和 V 6 分,当验证和后量子的权重被抽得很高时就会发生。Y 那一侧则是一次真正的掷硬币,而且没有被拿去主张任何东西:Y 在 36% 的 ±1 噪声采样和 50.0% 的采样权重下低于 5.00,而那个被标注为判断的 T 分项会把它推到任一侧(T 5 给出 4.85,T 7 给出 5.35)。
  • Mysterium(−0.10)、Hola(−0.15)和 Sentinel(−0.20)落在 Y 线下方的噪声之内,它们在线上还是线下并不是一个有把握的判断;它们在 X 上的欠缺才是信号。
  • 从上方看,TunnelBearIPVanishHotspot Shield 靠数据中心级的 L 和 T 分数,仅以 0.45–0.50 越过 Y 线;一位把它们的机队稳定性少打一分的读者,会把它们放到线上或线下。
  • 对其他所有人来说,X 线是一片清水。除了 Apple,没有任何东西比 0.40 更靠近它(左边是 Windscribe;右边是 0.70 处的 IVPN 和 1.60 处的 Obscura),所以没有任何单点重新评分能把其他任何产品挪过可验证与基于信任之间的那条分界。Apple 是唯一一个它落在这条线哪一侧确实取决于单个判断结论的产品。

会改变次序的重新加权

固定的权重把结构(S + E = 0.60)放在验证(V = 0.30)之上。把权重朝验证方向推得够狠,例如 E 0.15 / S 0.25 / V 0.50 / PQ 0.10,那么在这条轴上 Mullvad(7.00)就会超过 URnetwork(6.90);把验证权重再往上推,IVPN 最终也会超过它。在默认开启那次重新评分之前,一个更温和的例子就够了(V 取 0.45),而整片空间上的数字是一次掷硬币:在均匀采样的权重上,URnetwork 当时领先 Mullvad 的比例只有 55.55%。重新评分之后它领先 74.0%,而 V 0.45 那个例子不再翻转次序(URnetwork 7.00,Mullvad 6.70)。这条轴顶端的次序如今既依赖权重,也依赖那些结构分项得分,但一位把审计和法庭证据的权重定在整条轴一半左右的读者,仍然会得到 Mullvad 在前,而他并没有错。逐个产品的页面早就在告诉那位读者:在 URnetwork 补上它的协议审计缺口之前,选 Mullvad 或 IVPN。

同一根杠杆往另一边拉,就是 IVPN 那个推演过的例子:一套结构主导的 E 0.35 / S 0.45 / V 0.10 / PQ 0.10 把 IVPN 压到 4.90,刚好在线的左边,而 Mullvad 恰好落在 5.00 的线上。IVPN 在右上象限的成员身份,取决于验证权重保持可观。冻结的权重让它过了关,而这份依赖仍然记录在这里。

在质量轴上,把 Eg 定在 0.20,正是抬高 URnetwork 优势、并压住 WARP、Private Relay 和 Tor 的那个东西。一位把出网身份视为小众需求的读者会压缩 Y 的分布,而 URnetwork 的 Y 排名在采样的权重空间里跑遍第 1 到第 15。评分方法页的一个早先版本,是以"没有任何重新加权能把 URnetwork 的 Y 压到 5.00 以下"这条主张来结束这段分析的;那条主张已被撤回,上面那一节记录了原因。

什么会挪动这份记录

有三件事会挪动本页的结果,而不是与它们争辩。Obscura 发布后量子,会让那唯一一对退化的比较拉平(见上面的一对一对比)。一次对延迟或吞吐的外部测量,会取代那些结构锚点,而被撤回主张那一节正把它们点名为质量轴的软地。以及任何依评分方法页的长期规则所做的重新评分,都会在整个场上重跑两项检验,就像 Obscura 的加入和默认开启那次重新评分所做的那样。

可复现脚本

这是交叉核对用的实现,跑在 22 个产品的场上,其中 URnetwork 那一行是重新评分之后的。它会重新生成自己在 review/index/SCORES.md 运行记录表里的那一行、URnetwork 的排名统计量,以及上面那张逐产品表(Python 3,numpy ≥ 2)。它在采样之前会断言每一个已发布的 X 和 Y 总分都能从分项得分矩阵精确重算出来,而抽样顺序对精确复现是有影响的。

import numpy as np

SEED, N = 271828, 200_000

# name, X parts [X1 e2e, X2 sep, X3 verif, X4 pq], Y parts [Y1 L, Y2 T, Y3 Eg, Y4 In, Y5 A]
# URnetwork X row is the post-rescore [8, 8, 6, 7]; pre-rescore was [7, 8, 6, 5]
PRODUCTS = [
    ("URnetwork",           [8, 8, 6, 7],    [5, 5, 8, 8, 7]),
    ("Tor",                 [10, 10, 10, 4], [2, 1, 1, 9, 9]),
    ("NymVPN",              [9, 9, 7, 0],    [5, 4, 2, 5, 4]),
    ("Obscura",             [8, 8, 6, 0],    [5, 6, 5, 5, 4]),
    ("Mullvad",             [3, 5, 9, 8],    [7, 8, 5, 5, 4]),
    ("IVPN",                [3, 5, 8, 8],    [7, 7, 5, 4, 4]),
    ("Apple Private Relay", [7, 7, 3, 0],    [8, 7, 2, 1, 2]),
    ("Windscribe",          [3, 5, 7, 0],    [7, 7, 6, 7, 7]),
    ("PIA",                 [3, 3, 8, 0],    [7, 7, 4, 5, 4]),
    ("ExpressVPN",          [3, 3, 5, 9],    [7, 8, 4, 6, 4]),
    ("Proton VPN",          [3, 3, 7, 0],    [7, 8, 4, 6, 8]),
    ("NordVPN",             [3, 3, 5, 6],    [7, 8, 4, 6, 4]),
    ("Tailscale",           [6, 2, 5, 0],    [9, 9, 5, 4, 6]),
    ("Cloudflare WARP",     [3, 4, 3, 5],    [9, 8, 2, 4, 8]),
    ("Surfshark",           [3, 3, 4, 5],    [7, 8, 4, 5, 4]),
    ("Orchid",              [3, 5, 3, 0],    [6, 3, 3, 3, 3]),
    ("Mysterium",           [3, 3, 3, 0],    [6, 4, 6, 3, 4]),
    ("TunnelBear",          [3, 2, 4, 0],    [7, 6, 3, 5, 5]),
    ("Sentinel",            [3, 3, 2, 0],    [6, 4, 4, 6, 4]),
    ("IPVanish",            [3, 2, 3, 0],    [7, 7, 3, 4, 4]),
    ("Hotspot Shield",      [3, 1, 2, 0],    [7, 7, 2, 5, 5]),
    ("Hola",                [1, 0, 0, 0],    [5, 4, 5, 3, 7]),
]
WX = np.array([0.25, 0.35, 0.30, 0.10])
WY = np.array([0.30, 0.25, 0.20, 0.10, 0.15])
names = [p[0] for p in PRODUCTS]
XS = np.array([p[1] for p in PRODUCTS], float)
YS = np.array([p[2] for p in PRODUCTS], float)
UR, MV = names.index("URnetwork"), names.index("Mullvad")
OB, NY = names.index("Obscura"), names.index("NymVPN")

# sanity: every published total must recompute exactly (SCORES.md tables, product order above)
EXP_X = [7.30, 9.40, 7.50, 6.60, 6.00, 5.70, 5.10, 4.60, 4.20, 4.20, 3.90,
         3.90, 3.70, 3.55, 3.50, 3.40, 2.70, 2.65, 2.40, 2.35, 1.70, 0.25]
EXP_Y = [6.20, 3.30, 4.00, 5.10, 6.20, 5.85, 4.95, 6.80, 5.75, 6.10, 6.70,
         6.10, 7.25, 6.70, 6.00, 3.90, 4.90, 5.45, 4.80, 5.45, 5.50, 4.85]
assert np.allclose(XS @ WX, EXP_X) and np.allclose(YS @ WY, EXP_Y)

def ranks(scores):  # rank = 1 + #{strictly greater}
    return 1 + (scores[:, None, :] > scores[:, :, None]).sum(axis=2)

def q(a, p):
    return np.quantile(a, p, method="nearest")

def sim_A(rng, subs, w, chunk=50_000):
    R, S = [], []
    for start in range(0, N, chunk):
        m = min(chunk, N - start)
        sc = rng.dirichlet(np.ones(len(w)), size=m) @ subs.T
        R.append(ranks(sc)); S.append(sc)
    return np.vstack(R), np.vstack(S)

def sim_B(rng, subs, w, chunk=20_000):
    R, S = [], []
    for start in range(0, N, chunk):
        m = min(chunk, N - start)
        noisy = np.clip(subs[None] + rng.uniform(-1, 1, (m,) + subs.shape), 0, 10)
        sc = noisy @ w
        R.append(ranks(sc)); S.append(sc)
    return np.vstack(R), np.vstack(S)

rng = np.random.default_rng(SEED)   # draw order matters for exact reproduction:
rAX, sAX = sim_A(rng, XS, WX)       # 1) test 1, X axis
rAY, sAY = sim_A(rng, YS, WY)       # 2) test 1, Y axis
rBX, sBX = sim_B(rng, XS, WX)       # 3) test 2, X axis
rBY, sBY = sim_B(rng, YS, WY)       # 4) test 2, Y axis

print("exact P(UR>MV | test 1) = 71/96 =", 71 / 96)   # pre-rescore: 109/196
print("test 1: P(UR>MV on X) =", (sAX[:, UR] > sAX[:, MV]).mean())
print("test 2: P(UR>MV on X) =", (sBX[:, UR] > sBX[:, MV]).mean())
print("exact P(UR>Obscura | test 1) = 1 (pure X4 difference; pre-rescore: 5/6)")
print("test 1: P(UR>Obscura on X) =", (sAX[:, UR] > sAX[:, OB]).mean())
print("test 2: P(UR>Obscura on X) =", (sBX[:, UR] > sBX[:, OB]).mean())
print("exact P(UR>NymVPN | test 1) = 343/512 =", 343 / 512)
print("test 1: P(UR>NymVPN on X) =", (sAX[:, UR] > sAX[:, NY]).mean())
print("test 2: P(UR>NymVPN on X) =", (sBX[:, UR] > sBX[:, NY]).mean())
print("test 2: UR X score 5-95%:", q(sBX[:, UR], 0.05), q(sBX[:, UR], 0.95))
print("test 2: UR Y score 5-95%:", q(sBY[:, UR], 0.05), q(sBY[:, UR], 0.95))
print("test 2: UR Y < 5.00 in", int((sBY[:, UR] < 5).sum()), "of", N)
for label, r in (("AX", rAX), ("BX", rBX), ("AY", rAY), ("BY", rBY)):
    print(label, "UR rank p5/med/p95:", int(q(r[:, UR], .05)), int(q(r[:, UR], .5)), int(q(r[:, UR], .95)))
print("| Product | X rank, weights | X rank, score noise | Y rank, weights | Y rank, score noise |")
print("|---|---|---|---|---|")
f = lambda r, j: f"{int(q(r[:, j], .05))}–{int(q(r[:, j], .95))} ({int(q(r[:, j], .5))})"
for j in np.argsort(-(XS @ WX), kind="stable"):
    print(f"| {names[j]} | {f(rAX, j)} | {f(rBX, j)} | {f(rAY, j)} | {f(rBY, j)} |")