序贯重测的假阳性膨胀:同一条假设测 10 次,误报率从 5% 抬到 40%

2 阅读1分钟

上一个冬天被否掉的假设,今年换了数据和样本区间,看起来又活了。于是有人提议「再试一次」。

这句话在大多数团队里不触发任何流程。它不占预算,不进入试验台账,甚至不算一次「决定」——它只是一次重跑。但它是一次新的检验机会,而每次机会都在悄悄花掉显著性水平这份公共资源。本文把它拆成可计量的东西:预注册该写的三个字段,合成实验量出的代价,以及限次之后还剩多少真信号。所有数字来自文末脚本,种子 20261104,样本全部合成。

一、k 是尝试次数,不是假设个数

先把最容易混的两个量分开。

m(假设个数):这一轮提出了多少个不同的假设。批内多重检验的教科书讨论都在这个量上,做法是把 m 个 p 值一起处理,控制族错误率或错误发现率。

k(尝试次数):同一个假设被拿去检验了多少次。换样本区间重跑、参数网格扫一遍、三个月后新数据到了再跑一次——这些都算。

混淆的后果不是学术性的。一张 p 值矩阵就能看出落差:20 列数据,当成 20 个不同假设各测一次、用 BH 在 q=0.05 下兜住,至少误拒一个的概率是 0.050850;当成一个假设在 20 个独立样本上重测 20 次、不做预算,至少误拒一次的概率是 0.641200。比值 12.6096。同一个数据形状差了十二倍,差别不在数据,在有没有人为「机会」记账。

单次检验的 α 是一次机会的价格。价格标在单次上,花掉的却可以累加。若每次检验相互独立、原假设为真,k 次机会至少命中一次的概率是

FWER(k) = 1 - (1 - α)^k

k=10、α=0.05 时是 0.401263;k=20 是 0.641514。把假假设反复测二十次,六成多的概率你会拿到一个「显著」结果。实测(20000 个独立世界,每组 250 个观测):

  k= 1 emp=0.047500 theo=0.050000 x=1.00
  k= 2 emp=0.097950 theo=0.097500 x=1.95
  k= 3 emp=0.141950 theo=0.142625 x=2.85
  k= 5 emp=0.223950 theo=0.226219 x=4.52
  k=10 emp=0.402800 theo=0.401263 x=8.03
  k=20 emp=0.641200 theo=0.641514 x=12.83
  ever=0.641200 mean_k_hit=8.8236

经验值与解析值在万分之一内吻合。膨胀倍数是 1-(1-α)^k 除以 α:k=5 是 4.52 倍,k=10 是 8.03 倍,k=20 是 12.83 倍。

注意最后一行:命中者平均是在第 8.8236 次才第一次越线。你在第 3 次停手,绝大多数时候什么都看不到;你之所以"终于看到",恰恰因为你一直在试。8.8236 不是运气不好,是「等到一个假阳性」的期望时长。

更隐蔽的一层:k 在实践中会被系统性低估。回忆某个假设被验过几次,通常只数得出正式的那几次。被删掉的分支、改参数名后重跑的、数据修好后重跑的、觉得"那次口径不对"而没登记的重跑,都不进回忆。真实 k 往往比台账上的大好几倍。低估 k 不会让误报率变小,只会让你以为它是 5% 而实际是 40%。

这是与 BH-FDR 不同的另一条膨胀通道:BH 的问题是「同一批里假设太多」,解药是调阈值;序贯重测的问题是「同一个假设被问了太多次」,任何基于单次 p 值的阈值都不再生效——被调整的量根本不在那张表里。

二、预注册章节要写的三个字段

把「再试一次」写进流程只需要三样东西,关键是必须在看到新数据之前定好。

字段一:触发条件的冻结时点。 最容易写坏的一句是「若后续出现显著改善则解冻」。它在语义上等于没有约束:任何一次重跑都可能出现改善,而「改善到什么程度算显著」永远是事后才有答案。可执行的写法是把触发条件写成与数据无关的谓词:独立样本达到某个规模且覆盖期与上次不重叠,或者出现上次归档时未覆盖的机制性变化、且该变化可被独立数据验证。判据里不能出现「表现好于 X」,只能出现「结构上出现了什么」。

判断条款合不合格有个粗糙但好用的检验:把新数据全部删掉,这条条款还能不能判断真假。 能,是谓词;不能,是描述。「若新样本上结论方向一致则解冻」看着像谓词,其实是描述——「方向一致」本身就要看数据才能回答。

反直觉的是,条款写得越具体,越不像是保护自己的。「若出现显著改善且逻辑自洽则可解冻」读起来稳妥;「独立样本不重叠,最多三次,预算等分」读起来很硬。但前者执行时等于把决定权交还给会上谁说服力更强,后者才交给一个可复算的函数。

字段二:次数上限与 α 预算。 预算就是给这一族假设分配的显著性总量,次数上限是它的一次切分。两种切法可直接对比:等分让第 i 次的预算 α_i = α/k,每次检出线一样;根号切法 α_i ∝ 1/√i,越往后越严。

eq = np.full(k, alpha / k)              # 等分
w = 1.0 / np.sqrt(np.arange(1, k + 1))  # 根号
sq = alpha * w / w.sum()

两种切法的预算合计都是 0.05000000,独立假设下 FWER 分别是 0.048890 和 0.048909,都守住了名义水平。差别在形状:等分 k=10 后每次都要 |t|>2.807034;根号切法第一次只要求 2.577278(比名义的 1.959964 松),到第十次要 2.952801。它对「效应一开始就该在」的假设更友好,对「效应要再积累几年才出现」的更不友好。

顺带一个数字提醒限次的代价:等分 k=10 之后,单次检出线从 1.959964 抬到 2.807034,抬升 43.2186%。这个抬升不是惩罚,是账单——它精确反映了「多给九次机会」的价格。

字段三:单向的状态迁移。 复活失败后不许回退成「待观察」再试第三次。这一条必须写进代码,写在文档里不算。最小状态机只有三个状态:

class S(Enum):
    ARCHIVED, REVIVING, TERMINAL = "A", "R", "T"   # 归档 / 复活中 / 终局

LEGAL = {(S.ARCHIVED, S.REVIVING), (S.REVIVING, S.REVIVING),
         (S.REVIVING, S.TERMINAL), (S.ARCHIVED, S.TERMINAL)}

四条合法迁移,其余全部拒绝。注意 REVIVING→REVIVING 这条自环:它让「又试了一次、又没过」有地方落账,而不是变成一次无痕重跑。每次走这条边都要扣次数、扣预算。实测跑一串混合动作:

  接受 ARCHIVED->REVIVING [REVIVING 1/3 0.016667]
  接受 REVIVING->REVIVING [REVIVING 2/3 0.033333]
  接受 REVIVING->REVIVING [REVIVING 3/3 0.050000]
  拒绝 次数已用尽 3/3 [REVIVING 3/3 0.050000]
  接受 REVIVING->TERMINAL [TERMINAL 3/3 0.050000]
  拒绝 TERMINAL->REVIVING 非法 [TERMINAL 3/3 0.050000]

两个拒绝各挡一种事故:前一个挡「次数用尽还想再来一次」,后一个挡「归档了但不服气,改回待观察」。后者在人工流程里几乎必然发生——不需要撒谎,只需要有人说「这次情况确实特殊」。

这就是为什么条款要写成代码:它不阻止你改主意,它阻止你悄悄改主意。

顺带回答一个反对意见:把次数卡死会不会把新发现挡在外面。不会,因为卡住的从来不是新假设,是旧假设的重跑。新机制、新数据维度带来的提案开新条目,有自己的预算。把这两件事混在一起,才会得出「限次阻碍创新」的结论。代码层面也简单:假设有主键,复活记录挂在主键下,预算随主键走。新主键=新预算,旧主键重跑=扣旧预算。

三、限次之后还剩多少真信号

前面都在守 α,代价在功效那一侧。做一个扫描:真实效应存在,效应量取到让单次检验功效约 0.50,然后看 k 从 1 到 20、在 α_i = α/k 下,族错误率和功效各是多少。

  k= 1 a_i=0.0500000000 fwer=0.047500 power=0.491500 gain=0.491500
  k= 3 a_i=0.0166666667 fwer=0.049450 power=0.697600 gain=0.206100
  k= 5 a_i=0.0100000000 fwer=0.049000 power=0.784850 gain=0.087250
  k=10 a_i=0.0050000000 fwer=0.048950 power=0.887950 gain=0.103100
  k=20 a_i=0.0025000000 fwer=0.049650 power=0.951250 gain=0.063300
  one_shot=0.499746 p3=0.697600 p10=0.887950 d1=0.388204 d3=0.190350

FWER 一路稳在 0.05 附近(0.047500 到 0.049650),说明有预算的限次确实把族错误率拉回名义水平,代价没转嫁到假阳性上。功效从 0.491500 爬到 0.951250,说明多试几次确实能救回真信号——前提是每次都付了预算。净收益那列是这一档功效与前一个采样档之差:k 从 1 到 3 涨 0.206100,到 5 只涨 0.087250,到 20 只涨 0.063300。采样档间隔不等,只能读形状,不能把某个增量当成"多一次机会值多少"引用。形状本身就是结论:第三次机会的边际价值是第一次的四成多,第二十次只剩一成出头。「再试一次」不是免费的,越往后越贵。

单次机会的解析功效 0.499746 与经验值 0.491500 的差在 20000 次抽样的抖动范围内。k=10 等分的经验功效 0.887950,比单次机会高 0.388204——这就是「愿意付十次预算」买回来的东西。

还有一层容易被忽略:独立假设在重测里通常不成立。 很多「再试一次」是换汤不换药——同一批数据切不同窗口、同一份样本换不同分组变量。这时 t 统计量之间有正相关,多给的机会买不到那么多独立信息。

  rho=0.0 k=10 fwer=0.049100 power=0.892900
  rho=0.3 k=10 fwer=0.043600 power=0.718700
  rho=0.6 k=10 fwer=0.033400 power=0.557150
  rho=0.9 k=20 fwer=0.010450 power=0.308150
  k_eff rho=0.9 k=20 = 1.1050

相关越高功效掉得越狠:rho=0.9、k=20 时只剩 0.308150,比单次机会的 0.491500 还低——机会分掉了同一份预算,却没换来新信息。等效独立次数 k/(1+(k-1)·rho) 在 rho=0.9、k=20 时只有 1.1050。二十次尝试,一点一五次的信息。 所以条款还该有第四个字段:复活必须换独立样本。

四、预算-限次-功效的三角

读数放一起,取舍就清楚了。

预算不能省。 无预算时 k=20 的误报率是 0.641200,有预算时稳定在 0.049650 附近。这差距不是靠"更小心地解读"能补的,因为误报率来自机会计数,不来自解读。

限次不能太紧。 k=3 到 k=10 功效涨了 0.190350(0.697600 到 0.887950),代价是净收益从 0.206100 降到 0.103100——用一半的边际收益换接近翻倍的功效,划算。该收紧的是 k 过十以后:功效只涨 0.063300,预算却被摊薄到每次 0.0025。

切分形状要匹配假设的出现时序。 效应一开始就在,等分与根号是 0.887950 对 0.880900,差 0.007050;效应到第六次才出现,变成 0.668700 对 0.602250,差 0.066450。因为根号把预算前置,后面的机会更贵,效应越晚越吃亏。条款允许"效应迟到",就别用前置型切分。 一句话:把 α 当成一辈子能花的机会预算来管,而不是每次检视都能重新领的配额。

五、可执行的三条

  1. 台账的列从「假设」改成「假设-尝试」。 主键是假设,每行记一次尝试:样本标识、检验日期、消耗的 α_i、剩余预算。只记假设的台账,天然看不见序贯膨胀。
  2. 触发条件写成谓词,不写成趋势描述。 验收标准是"把新数据删掉还能判断真假"。
  3. 状态机里必须有吸收态,且 REVIVING 自环要扣预算。 前者挡回退,后者保证自环不是无痕的。写进代码后,说服成本从"靠评审的确信"降到"跑一个测试"。

一次被否掉的假设重新开测,和一次全新假设开测,花的是同一份显著性预算,只是前者从来没人记账。

六、完整脚本

下文所有数字由这段脚本打印,numpy / scipy 即可运行,种子 20261104。

import numpy as np
from scipy import stats
from enum import Enum

SEED, A, N, W, KMAX = 20261104, 0.05, 250, 20000, 20
rng = np.random.default_rng(SEED)

def pv(delta=0.0):                    # W 组各 N 个观测,双边 t 检验
    x = rng.standard_normal((W, N)) + delta
    t = x.mean(1) / (x.std(1, ddof=1) / N ** 0.5)
    return 2 * stats.t.sf(np.abs(t), N - 1)

null = np.stack([pv() for _ in range(KMAX)], 1)       # 原假设为真
for k in (1, 2, 3, 5, 10, 20):                        # A 无预算重测
    emp = np.mean(np.any(null[:, :k] < A, 1))
    print(f"k={k:2d} emp={emp:.6f} theo={1-(1-A)**k:.6f} x={(1-(1-A)**k)/A:.2f}")
hit = np.argmax(null < A, 1)
ever = (null < A).any(1)
print(f"ever={ever.mean():.6f} mean_k_hit={hit[ever].mean()+1:.4f}")

K = 10                                                # B 预算切分
eq = np.full(K, A / K)
w = 1 / np.sqrt(np.arange(1, K + 1))
sq = A * w / w.sum()
for i in (0, 1, 4, 9):
    print(f"i={i+1:2d} eq={eq[i]:.8f} sq={sq[i]:.8f} "
          f"z_eq={stats.norm.isf(eq[i]/2):.6f} z_sq={stats.norm.isf(sq[i]/2):.6f}")
print(f"sum={eq.sum():.8f} fwer_eq={1-np.prod(1-eq):.6f} fwer_sq={1-np.prod(1-sq):.6f}")
zn, z10 = stats.norm.isf(A / 2), stats.norm.isf(eq[0] / 2)
print(f"z_nominal={zn:.6f} z_eq_k10={z10:.6f} lift={z10/zn-1:.4%}")

D = 1.9606 / np.sqrt(N)                               # C 限次扫描
NCP = D * np.sqrt(N)
print(f"z_target=1.9606 power_target=0.50 D={D:.6f}")
tf = np.stack([pv(D) for _ in range(KMAX)], 1)
prev, p3, p10 = 0.0, 0.0, 0.0
for k in (1, 3, 5, 10, 20):
    ai = A / k
    fw = np.mean(np.any(null[:, :k] < ai, 1))
    pw = np.mean(np.any(tf[:, :k] < ai, 1))
    print(f"k={k:2d} a_i={ai:.10f} fwer={fw:.6f} power={pw:.6f} gain={pw-prev:.6f}")
    p3, p10 = (pw if k == 3 else p3), (pw if k == 10 else p10)
    prev = pw
one = 1 - stats.norm.sf(zn - NCP)
print(f"one_shot={one:.6f} p3={p3:.6f} p10={p10:.6f} "
      f"d1={p10-one:.6f} d3={p10-p3:.6f}")
for s, nm in ((0, "first"), (5, "late")):             # D 切分形状
    pe = np.mean(np.any(tf[:, s:K] < eq[s:K], 1))
    ps = np.mean(np.any(tf[:, s:K] < sq[s:K], 1))
    print(f"{nm} eq={pe:.6f} sq={ps:.6f} diff={pe-ps:.6f}")

def ct(k, ncp, rho):                                  # E 尝试相关性
    return np.sqrt(rho)*rng.standard_normal((W, 1)) + \
           np.sqrt(1-rho)*rng.standard_normal((W, k)) + ncp

for rho in (0.0, 0.3, 0.6, 0.9):
    for kk in (10, 20):
        c = stats.norm.isf(A / kk / 2)
        fw = np.mean(np.any(np.abs(ct(kk, 0.0, rho)) > c, 1))
        pw = np.mean(np.any(ct(kk, NCP, rho) > c, 1))
        print(f"rho={rho:.1f} k={kk:2d} fwer={fw:.6f} power={pw:.6f} "
              f"k_eff={kk/(1+(kk-1)*rho):.4f}")

class S(Enum):                                        # F 状态机
    ARCHIVED, REVIVING, TERMINAL = "A", "R", "T"

LEGAL = {(S.ARCHIVED, S.REVIVING), (S.REVIVING, S.REVIVING),
         (S.REVIVING, S.TERMINAL), (S.ARCHIVED, S.TERMINAL)}

def go(st, tg, used, cap, spent):
    if (st, tg) not in LEGAL:
        return st, f"reject {st.value}->{tg.value} illegal"
    if tg is S.REVIVING and (used >= cap or spent >= A - 1e-12):
        return st, f"reject cap={used}/{cap}"
    return tg, f"accept {st.value}->{tg.value}"

st, cap, used, spent = S.ARCHIVED, 3, 0, 0.0
for tg in (S.REVIVING,)*4 + (S.TERMINAL, S.REVIVING):
    st, msg = go(st, tg, used, cap, spent)
    if msg.startswith("accept") and tg is S.REVIVING:
        used, spent = used + 1, spent + A / cap
    print(f"{msg} [{st.value} {used}/{cap} {spent:.6f}]")

def bh(row, q):                                       # G 同一矩阵两种解读
    o, m = np.sort(row), row.size
    return bool((o <= q * np.arange(1, m + 1) / m).any())

bhr = np.mean([bh(null[i], A) for i in range(W)])
raw = np.mean(np.any(null[:, :KMAX] < A, 1))
print(f"m={KMAX} bh={bhr:.6f} raw={raw:.6f} ratio={raw/bhr:.4f}")

本文用合成数据演示序贯重测的错误率膨胀与预算切分,所有概率、阈值、功效均为构造值,不涉及任何真实策略业绩、回测曲线、因子构造或实盘结论,不构成投资建议。文中的 α 切分公式与状态机为通用统计原语,替换成本模型或检出方法不改变量级结论。代码为独立实现,种子 20261104 固定,可逐位复算。若需完整脚本与重测预算自检清单,可在评论区留言「验真」。