halo 的技术博客

返回

因子库里最贵的错觉之一:把旧东西换皮,当成新发现。

你辛苦挖出一个因子,IC 漂亮、分层单调、多空年化 20%。然后有人问:「它跟市值、价值、动量相关多少?」你一算,相关性 0.85。那它根本不是新因子——它只是旧模型里早就有的一坨东西,被你换了个名字重新打包。

怎么用统计把这事钉死?答案是 因子跨越检验(spanning test)。它的零假设很直白:新因子没有增量信息 = 所有资产对新因子的载荷联合为零。要么被旧模型「跨越(span)」了,要么真带来了新东西。本文用 GRS 型联合 F 检验,把判决做成可运行的数字。

一、设定:已有旧模型,来了候选新因子#

设我们有一个旧因子模型(本文只有市场因子 mktmkt),和 KK 个测试组合(或资产)的超额收益 RRT×KR \in \mathbb{R}^{T\times K}。现在来了一个候选新因子 gg。对每个资产 ii 做回归:

Ri=ai+bimkt+cig+eiR_i = a_i + b_i \cdot mkt + c_i \cdot g + e_i

  • cic_i 是资产 ii新因子的载荷
  • 跨越检验的零假设:H0:c1=c2==cK=0H_0: c_1 = c_2 = \dots = c_K = 0(新因子对所有资产都无载荷 = 无增量)
  • 备择:至少有一个 ci0c_i \neq 0

如果 H0H_0 成立,加不加 gg 都一样,旧模型已经「跨越」了它能解释的全部;gg 只是冗余噪声。如果拒绝 H0H_0,说明 gg 解释了旧模型解释不了的变动——它是真增量。

二、统计量:受限 vs 无约束的残差平方和之比#

分别跑两个回归,比较残差平方和(SSR):

  • 受限模型:Ri[1,mkt]R_i \sim [1, mkt],SSRr_r
  • 无约束模型:Ri[1,mkt,g]R_i \sim [1, mkt, g],SSRu_u

加一个新因子若真有用,SSRr_r 应该明显大于 SSRu_u。把它们做成联合 F 统计量:

F=(SSRrSSRu)/KSSRu/(Tp),p=3F = \frac{(\text{SSR}_r - \text{SSR}_u)/K}{\text{SSR}_u/(T-p)}, \qquad p = 3

H0H_0FFK,TpF \sim F_{K,\,T-p}KK 是限制个数(=资产数),pp 是无约束参数数(截距+市场+新因子=3)。p 值小 → 拒绝 H0H_0 → 新因子有增量。

三、跑数据:两个对照场景#

我们造 8 个测试组合(K=8),都由市场因子 + 特质驱动,观测 240 个月(T=240)。F 临界值(5%, df=(8,237)) = 1.978

场景 A — 候选因子是纯噪声(与资产收益正交,应被跨越):

def spanning_test(R, mkt, g):
    Tt, Kk = R.shape
    Xr = np.column_stack([np.ones(Tt), mkt])        # 受限
    Xu = np.column_stack([np.ones(Tt), mkt, g])     # 无约束
    def ssr(X, y):
        b, *_ = np.linalg.lstsq(X, y, rcond=None)
        return float((y - X @ b) @ (y - X @ b))
    SSR_r = sum(ssr(Xr, R[:, i]) for i in range(Kk))
    SSR_u = sum(ssr(Xu, R[:, i]) for i in range(Kk))
    F = ((SSR_r - SSR_u) / Kk) / (SSR_u / (Tt - 3))
    pval = 1.0 - stats.f.cdf(F, Kk, Tt - 3)
    c = np.array([np.linalg.lstsq(Xu, R[:, i], rcond=None)[0][2]
                  for i in range(Kk)])
    return F, pval, c
python

场景 B — 候选因子对部分组合有真实载荷(真增量):

g_B = rng.normal(0.005, 0.025, T)      # 独立新因子
R_B = R.copy()
for i in range(K):
    if i % 2 == 0:                      # 第 1/3/5/7 组合对 g_B 有 +0.5 真实载荷
        R_B[:, i] += 0.5 * g_B
python

结果:

场景F 统计量p 值判断
A(冗余因子)0.1890.9923不拒绝 H0(无增量)
B(增量因子)6.108≈0.0000拒绝 H0(有增量)

场景 A 的 F 远低于临界值 1.978,冗余因子没贡献任何解释力;场景 B 的 F 是临界值的 3 倍,p 值直接贴 0——新因子被判定为真增量。

跨越检验 F 统计量 vs F 临界值

跨越检验 p 值(对数轴) vs 显著性阈值 0.05

四、载荷长什么样:把判决拆回资产层面#

检验通过,不代表每个资产都吃新因子。看各资产对新因子的载荷 cic_i:

  • 场景 A:各资产 cic_i 绝对值均值 0.071,正负乱跳,贴近 0——冗余因子的典型签名
  • 场景 B:偶数组合(真实载荷 0.5)还原出 0.510,奇数组合 −0.017(本就无载荷)——检验不仅判了「有增量」,还把增量落到了对的资产上

各资产对候选新因子的载荷 c_i: 冗余 vs 真增量

再用 R² 增量交叉验证:加入新因子后,场景 A 平均 R² 只多了 0.0014(几乎为 0),场景 B 多了 0.0471——两个角度(联合 F 与 R² 增量)互相印证。

加入新因子的解释力增量: 冗余 vs 真增量

五、六类真实陷阱(实战必看)#

  1. T 不够大、K 太大:GRS/F 检验的近似要求 TKT \gg K。本文 T=240、K=8 很宽裕;若你拿 60 个月数据测 50 个资产,F 临界值本身就不稳,结论不可信。资产数最好 ≤ T/10。
  2. 一次加多个新因子:本文只加 1 个。若一次加 LL 个新因子,无约束参数 p=1+1+Lp = 1 + 1 + L,自由度变成 FK,TpF_{K,\,T-p},SSR 的拆分也要按 LL 个载荷联合判。漏改自由度会直接算错 p 值。
  3. 新因子与旧因子高相关:若 ggmktmkt 相关系数 0.9,无约束回归共线性爆炸,cic_i 估计方差飙升,F 检验的 power 暴跌——你会「假不拒绝」,把真增量漏掉。先查新因子和旧因子的相关,太高就先做正交化再测。
  4. 数据窥探 / 多重检验:你测了 100 个候选因子,只报那个 p<0.05 的?在 100 次检验下,光靠随机就有约 5 个会「显著」。必须做 Bonferroni 或 FDR(BH)校正,否则跨越检验变成 p-hacking。
  5. 测试资产样本内挑选:若 8 个测试组合是「专门挑出能拒绝 H0 的那几个」,结论严重样本内偏差。正确做法用独立的、事先固定的测试资产(如标准规模-价值组合网格),不要按结果反选。
  6. 因子的 look-ahead:候选因子 gg 若用未来信息构造(比如用期末才公布的财务数据去匹配月初收益),F 检验会被人为抬高,把实际上不可交易的「未来信息」误判成增量。因子与收益必须严格对齐同一信息集。

六、小结#

因子跨越检验把「新因子到底新不新」从主观争论变成联合 F 判决:受限模型([1,mkt])vs 无约束模型([1,mkt,g])的 SSR 之比,在 H0 下服从 FK,TpF_{K,T-p}。本文实证干净利落——冗余因子 F=0.19、p=0.99(不拒绝),真增量因子 F=6.11、p≈0(拒绝),且把 0.5 的真实载荷还原成 0.51。记住它的位置:它是「这个因子值不值得进模型」的守门员,不是「这个因子能不能赚钱」的保证书。 过了跨越检验,只说明它带来了旧模型没有的信息维度,至于实盘赚不赚,那是另一场检验。


代码与图表均由 Python(numpy + scipy + matplotlib)真实计算,数据为带结构的可复现合成面板,非占位符。

因子跨越检验:用 GRS 检验判断新因子是否真有增量信息
https://blog.halo26812.eu.org/blog/factor-spanning-test
Author halo
Published at 2026年7月18日
版权声明 CC BY-NC-SA 4.0
Comment seems to stuck. Try to refresh?✨