上一个冬天被否掉的假设,今年换了数据和样本区间,看起来又活了。于是有人提议「再试一次」。
这句话在大多数团队里不触发任何流程。它不占预算,不进入试验台账,甚至不算一次「决定」——它只是一次重跑。但它是一次新的检验机会,而每次机会都在悄悄花掉显著性水平这份公共资源。本文把它拆成可计量的东西:预注册该写的三个字段,合成实验量出的代价,以及限次之后还剩多少真信号。所有数字来自文末脚本,种子 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。因为根号把预算前置,后面的机会更贵,效应越晚越吃亏。条款允许"效应迟到",就别用前置型切分。 一句话:把 α 当成一辈子能花的机会预算来管,而不是每次检视都能重新领的配额。
五、可执行的三条
- 台账的列从「假设」改成「假设-尝试」。 主键是假设,每行记一次尝试:样本标识、检验日期、消耗的 α_i、剩余预算。只记假设的台账,天然看不见序贯膨胀。
- 触发条件写成谓词,不写成趋势描述。 验收标准是"把新数据删掉还能判断真假"。
- 状态机里必须有吸收态,且 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 固定,可逐位复算。若需完整脚本与重测预算自检清单,可在评论区留言「验真」。