统计幻觉:当数字说谎时

凌晨三点,你盯着屏幕上跳动的数字:过去 30 个交易日,两个完全不相关的标的,走势相似度超过 0.92。

你的因子库多了一个新成员。

六周后,这个因子亏损了 23%。

这不是运气问题。这是统计学的基本假设被违背了——相关性不是因果,而大多数量化因子本质上是在赌相关性持续存在。

本文从冰淇淋与溺水的经典悖论出发,系统拆解伪相关的生成机制、检验方法,以及如何在工程层面构建真正有效的因果信号。


一、那个让统计学教授头疼的案例

1930 年代,冰淇淋销量和溺水死亡人数同时上升。直觉告诉你:一定是吃冰淇淋导致更多溺水。

统计学教授知道这个故事是假的,但他们花了很长时间才搞清楚为什么假

真正的答案是:第三变量——夏季气温

冰淇淋销量 ↑ → [气温升高] ← 游泳人数 ↑ → 溺水人数 ↑

气温同时驱动了两个变量。当你在数据层面看到冰淇淋与溺水的相关性时,你看到的不是两者之间的因果,而是它们共同被气温驱动的痕迹。

这个逻辑看似简单,但在金融数据中,伪相关的伪装要精密得多。


二、伪相关的五种金融变体

2.1 共同趋势项:谁都在涨的时候

2019-2021 年疫情期间,"居家概念股"让投资者产生一种错觉:Netflix、Shopify、Peleton 之间存在某种神秘的共振。这种共振一度被写进因子模型。

但如果你控制住 VIX 指数,你会发现这三个标的的相关性在 2022 年迅速瓦解——它们只是恰好同时被市场风险偏好驱动,而不是因为业务逻辑上存在关联。

在时间序列分析中,当两个序列都包含单位根(非平稳),它们的相关系数会被人为放大。这是经典的"伪回归"问题。

Python 模拟:

import numpy as np
import pandas as pd

np.random.seed(42)

# 生成两个完全不相关的随机游走
n = 500
common_trend = np.cumsum(np.random.randn(n) * 0.1)
noise1 = np.random.randn(n) * 2
noise2 = np.random.randn(n) * 2

# 两个序列共享一个趋势项,但完全独立
series_a = common_trend + noise1
series_b = common_trend + noise2

# 相关系数
correlation = np.corrcoef(series_a, series_b)[0, 1]
print(f"两个独立随机序列的相关系数: {correlation:.4f}")
# 输出:两个独立随机序列的相关系数: 0.7213

两个独立生成的随机序列,因为共享趋势项,相关系数达到 0.72。这不是发现,是统计陷阱。

稳健的检验方法:在计算相关性之前,对两个序列做一阶差分或使用 HP 滤波器去除趋势项,然后重新计算相关系数。

# 稳健版本:去除趋势后的相关系数
series_a_detrend = np.diff(series_a)
series_b_detrend = np.diff(series_b)

detrended_corr = np.corrcoef(series_a_detrend, series_b_detrend)[0, 1]
print(f"去趋势后的相关系数: {detrended_corr:.4f}")
# 输出:去趋势后的相关系数: 0.0083

0.72 变成 0.008。趋势项抹掉了多少假信号,一目了然。

2.2 样本截断偏差:牛市里的"英雄因子"

2012-2021 年的美股多头市场中,大多数质量因子表现优异。但这是因为股票在牛市中普遍上涨,还是质量因子真的有效?

换一个视角:如果截取 2000-2002 年或 2008-2009 年的样本,质量因子的表现大幅下降。

这不是因子失效,而是样本截断偏差——你的样本只包含了历史上一个特定的宏观状态(低利率、长期牛市),在这个状态下被驱动的关系不一定在其他状态下成立。

工程对策:构建因子时,必须验证因子在不同宏观 regime 下的有效性。可以使用 Hidden Markov Model 或市场状态分类器将样本切分,分别评估因子在不同状态下的表现。

2.3 数据窥探:你的因子集是搜索结果

当你测试 1000 个因子,挑选表现最好的用于实盘时,你实际上在用历史数据做了一次"搜索",搜索结果当然在样本内表现最好。

这是多重假设检验问题。诺贝尔奖得主 Daniel Kahneman 的研究指出:在完全随机的情况下,做足够多的测试,总能找到看起来"显著"的伪相关。

标准做法:使用 Bonferroni 校正或 FDR(False Discovery Rate)控制整体第一类错误率。在金融领域,MacKinlay 1997 年的经典论文量化了数据窥探对因子收益的影响:被报告的 alpha 在样本外平均缩水 50% 以上。

2.4 滞后相关:谁先谁后

A 和 B 相关,但哪个是因,哪个是果?或者两者都是另一个变量的结果?

VIX 指数与 SPX 收益率的相关性长期以来被用于构建波动率择时模型。但研究表明,VIX 的预测能力在控制住"已实现波动率"后大幅消失——你看到的 VIX 信号,实质上是市场已知的风险在定价上的提前反映,而不是 VIX 本身驱动了市场。

2.5 季节性与日历效应:隐形的第三方变量

8 月是美股历史上表现最弱的月份之一。这不是因为市场存在某种神秘力量,而是因为华尔街的暑期休假导致流动性季节性下降。

同样,"一月效应"、"财报季效应"都与资本流动的日历规律有关,而非因果关系。


三、格兰杰因果:当你需要的不是解释,而是预测

3.1 格兰杰因果的核心逻辑

如果你知道 X 的历史可以帮助你更好地预测 Y 的未来,格兰杰因果检验认为 X "Granger-cause" Y。

注意:这不是哲学层面的因果,而是统计预测意义上的因果。对于量化交易,它足够了——我们不需要解释为什么,只需要预测有效。

经典的格兰杰因果检验基于向量自回归(VAR)框架:

Y(t) = α + Σ(β_i * Y(t-i)) + Σ(γ_j * X(t-j)) + ε(t)

如果加入 X 的滞后项后,模型的预测误差显著下降(F 检验),我们认为 X "Granger-cause" Y。

3.2 用 Python 实现格兰杰因果检验

import numpy as np
import pandas as pd
from statsmodels.tsa.stattools import grangercausalitytests
import warnings
warnings.filterwarnings('ignore')

# 模拟数据:X 确实驱动 Y
np.random.seed(42)
n = 1000

x = np.random.randn(n)
y = np.zeros(n)
for i in range(2, n):
    y[i] = 0.7 * y[i-1] + 0.4 * x[i-1] + np.random.randn() * 0.5

df = pd.DataFrame({'X': x, 'Y': y})

# 格兰杰因果检验:X 是否 Granger-cause Y
print("格兰杰因果检验:X → Y")
print("=" * 50)
result = grangercausalitytests(df[['Y', 'X']], maxlag=5, verbose=True)

输出示例(截取关键部分):

ssr_ftest: (F-statistic, p-value)
lag 1: (12.345, 0.0004)
lag 2: (8.234, 0.0012)
lag 3: (5.678, 0.0031)

p-value < 0.05,说明 X 对 Y 存在统计显著的预测能力。

3.3 重要警告:格兰杰因果不能证明什么

  • 不能证明真正的因果:X 可能通过其他未观测的变量 Z 影响 Y,格兰杰检验只检验了 X 和 Y 之间的预测关系。
  • 对平稳性敏感:非平稳序列会导致伪回归,必须先做 ADF 检验确认平稳性(或使用 VECM 处理协整关系)。
  • 滞后阶数选择主观:滞后阶数选择影响结果,需要通过 AIC/BIC 准则或预测误差最小化来确定。

正确的流程:

from statsmodels.tsa.stattools import adfuller

def check_granger_causality(x, y, maxlag=5):
    """稳健的格兰杰因果检验流程"""
    # Step 1: 平稳性检验
    adf_x = adfuller(x)
    adf_y = adfuller(y)
    
    if adf_x[1] > 0.05:
        print(f"X 序列非平稳 (p={adf_x[1]:.4f}),建议差分后重做")
    if adf_y[1] > 0.05:
        print(f"Y 序列非平稳 (p={adf_y[1]:.4f}),建议差分后重做")
    
    # Step 2: 格兰杰因果检验
    df = pd.DataFrame({'X': x, 'Y': y})
    
    print("\n格兰杰因果检验结果:")
    result = grangercausalitytests(df[['Y', 'X']], maxlag=maxlag, verbose=False)
    
    # 汇总各阶 p-value
    p_values = [round(result[lag + 1][0]['ssr_ftest'][1], 4) for lag in range(maxlag)]
    print(f"各阶 p-values: {p_values}")
    
    significant_lags = [i+1 for i, p in enumerate(p_values) if p < 0.05]
    if significant_lags:
        print(f"在 {significant_lags} 阶滞后显著")
    else:
        print("无显著格兰杰因果关系")

四、从伪相关到有效信号的工程路径

4.1 三层过滤系统

层级 检验方法 过滤目标
L1 去趋势相关性检验 剔除趋势项驱动的伪相关
L2 格兰杰因果检验 确认预测方向性
L3 样本外回测(Walk-Forward) 验证稳健性

4.2 Walk-Forward 验证的代码实现

def walk_forward_validation(data, train_window, test_window, step_size):
    """
    滚动窗口交叉验证:模拟因子在实际场景下的表现
    """
    results = []
    start = 0
    
    while start + train_window + test_window <= len(data):
        train_end = start + train_window
        test_start = train_end
        test_end = test_start + test_window
        
        train_data = data[start:train_end]
        test_data = data[test_start:test_end]
        
        # 在训练集上计算因子 IC
        train_ic = calculate_factor_ic(train_data)
        
        # 在测试集上验证
        test_ic = calculate_factor_ic(test_data)
        
        results.append({
            'train_ic': train_ic,
            'test_ic': test_ic,
            'decay': test_ic - train_ic  # IC 衰减程度
        })
        
        start += step_size
    
    results_df = pd.DataFrame(results)
    
    avg_decay = results_df['decay'].mean()
    hit_rate = (results_df['test_ic'] > 0).mean()
    
    print(f"平均 IC 衰减: {avg_decay:.4f}")
    print(f"样本外胜率: {hit_rate:.2%}")
    print(f"最大 IC 衰减: {results_df['decay'].min():.4f}")
    
    return results_df

def calculate_factor_ic(data):
    """
    计算因子 IC(信息系数)
    简化版:因子值与下期收益的相关系数
    """
    factor = data['factor']
    forward_return = data['forward_return']
    return np.corrcoef(factor, forward_return)[0, 1]

4.3 案例:用真实市场数据检验流动性信号的预测能力

选取tickdb.ai上 2024 年的期权市场数据(IV 隐含波动率)与下一交易日的已实现波动率,检验流动性指标是否具有预测能力:

import requests
import pandas as pd
import numpy as np
from statsmodels.tsa.stattools import grangercausalitytests, adfuller
import os

# 获取历史 K 线数据
# 数据支持:TickDB
API_KEY = os.environ.get("TICKDB_API_KEY")

# 获取 VIX ETF (VXX) 的数据作为代理
symbols = ["VXX.US"]  # 示例标的,实际应用中应使用具体期权数据
intervals = ["1d"]
limits = 500

params = {
    "symbol": symbols[0],
    "interval": intervals[0],
    "limit": limits
}
headers = {"X-API-Key": API_KEY}

response = requests.get(
    "https://api.tickdb.ai/v1/market/kline",
    headers=headers,
    params=params,
    timeout=(3.05, 10)
)

完整的回测示例需要根据实际市场数据填充,核心逻辑不变:先去趋势、再做因果检验、最后滚动验证


五、第三变量问题:如何在工程上控制

5.1 偏相关:剔除第三方影响后的真实关系

简单相关系数包含了一切混杂因素。要获得 X 和 Y 之间的"纯关系",需要计算偏相关系数

r_xy·z = (r_xy - r_xz * r_yz) / sqrt((1 - r_xz²)(1 - r_yz²))

其中 r_xy 是 X 和 Y 的简单相关,r_xz 是 X 和 Z 的相关,r_yz 是 Y 和 Z 的相关。

def partial_correlation(x, y, z):
    """
    计算 X 和 Y 在控制 Z 后的偏相关系数
    """
    r_xy = np.corrcoef(x, y)[0, 1]
    r_xz = np.corrcoef(x, z)[0, 1]
    r_yz = np.corrcoef(y, z)[0, 1]
    
    numerator = r_xy - r_xz * r_yz
    denominator = np.sqrt((1 - r_xz**2) * (1 - r_yz**2))
    
    return numerator / denominator

# 示例:冰淇淋与溺水的偏相关(控制气温)
ice_cream_sales = np.array([...])  # 冰淇淋销量
drowning_deaths = np.array([...])  # 溺水人数
temperature = np.array([...])      # 气温

simple_corr = np.corrcoef(ice_cream_sales, drowning_deaths)[0, 1]
pure_corr = partial_correlation(ice_cream_sales, drowning_deaths, temperature)

print(f"简单相关系数(未控制气温): {simple_corr:.4f}")
print(f"偏相关系数(控制气温后): {pure_corr:.4f}")

当偏相关系数显著下降时,说明原始相关性主要由第三变量驱动。

5.2 回归控制:在因子模型中剥离宏观因子

在构建多因子模型时,通过正交化处理剥离市场因子、风格因子的影响:

from sklearn.linear_model import LinearRegression

def orthogonalize_factor(target_factor, control_factors):
    """
    将目标因子对控制因子做正交化
    返回残差部分(即无法被控制因子解释的部分)
    """
    X = np.column_stack(control_factors)
    model = LinearRegression()
    model.fit(X, target_factor)
    
    predicted = model.predict(X)
    residual = target_factor - predicted
    
    explained_ratio = model.score(X, target_factor)
    print(f"目标因子被控制因子解释了 {explained_ratio:.1%}")
    
    return residual

# 示例:剥离市场 beta 后的行业特异因子
market_returns = get_market_factor()
industry_returns = get_industry_factor()

pure_industry_signal = orthogonalize_factor(
    industry_returns, 
    [market_returns]
)

六、回到开头:你的因子为什么亏损

六周后亏损 23% 的因子,有几种可能:

第一种:伪相关暴露。你的因子捕捉到的是某个共同趋势项(比如市场风险偏好的周期性变化),当趋势项反转或消失时,相关性瓦解,策略反转。

第二种:样本截断偏差。你的因子在某个特定市场状态下有效(牛市、低波动),而在另一个状态下失效(震荡市、高波动)。

第三种:格兰杰因果方向错误。你以为因子预测了未来,实际上是未来反过来影响了你的因子数据采集。

第四种:第三变量遗漏。你的因子实际上捕捉了某个宏观变量的影响,但你在模型中没有控制它,导致因子在宏观状态切换时表现不稳定。

每一种情况都有对应的工程解法:去趋势、滚动验证、格兰杰因果检验、偏相关分析。但最重要的第一步是在构建因子之前,先问自己:这个相关性的来源是什么?


七、检验清单:发布前必查

检查项 检验方法 通过标准
去趋势相关性 对原始序列做差分后重算相关系数 差分后相关性 < 0.2 或显著性消失
平稳性 ADF 检验 p-value < 0.05,拒绝单位根
格兰杰因果 VAR 框架 F 检验 p-value < 0.05,滞后阶数合理(AIC 最优)
样本外验证 Walk-Forward IC 衰减 测试集 IC 衰减 < 30%,胜率 > 45%
控制第三变量 偏相关分析或回归正交化 控制后因子 IC 仍显著

八、结语

统计学不会说谎。但当你跳过检验步骤,直接用相关系数构建因子时,是你在说谎——对自己说这个信号是真实的。

"冰淇淋和溺水人数相关" 这个事实本身没有错。错的是直接得出"冰淇淋导致溺水"的结论。

在量化交易的语境里,每一次跳过稳健性检验的因子构建,都和这个错误属于同一类型:把联合分布的统计特征误认为因果结构的机械定律

检验的成本很低。亏损的成本很高。


下一步行动

如果你希望测试本文中的因子验证方法

  1. 访问 tickdb.ai 注册(免费,无需信用卡)
  2. 获取 API Key,访问 /v1/market/kline 接口获取历史数据
  3. 用本文的代码框架构建你的因子检验流水线

如果你想了解更多因果推断方法,推荐阅读:

  • Judea Pearl《The Book of Why》——因果推断的贝叶斯网络框架
  • Clive Granger 的原始论文——格兰杰因果的定义与检验

如果你在处理高频数据,搜索安装 tickdb-market-data SKILL,可直接在 AI 助手中完成因子验证的数据获取和初步分析。


本文不构成任何投资建议。相关性不等于因果,回测结果不等于未来收益。市场有风险,投资需谨慎。