开篇
"相关性不是因果性"——这是一句被重复了无数遍的统计学公理。
但讽刺的是,在量化交易的世界里,这句话的教训每隔几年就要被重新领教一遍。2015年A股股灾前夕,无数量化策略基于"M2增速与股指的强相关性"建立了"宽松货币→流入股市→上涨"的预测模型。它们在回测中表现优异,在实盘中灰飞烟灭。
原因很简单:M2、股指、GDP,三者共享同一个驱动力——经济周期。把经济周期的代理变量当成预测因子,本质上是在预测自己。
这不是一个理论困境。这是每一个量化研究员迟早会踩到的工程坑。
本文系统拆解"伪相关"的识别方法、格兰杰因果检验的原理与局限,以及如何在实际策略构建中建立更可靠的因果推断框架。
一、什么是伪相关
1.1 定义的数学表述
伪相关(Spurious Correlation) 指两个变量 $X$ 和 $Y$ 之间存在统计相关性,但这种相关性并非源于 $X \rightarrow Y$ 或 $Y \rightarrow X$ 的因果关系,而是由第三个变量 $Z$ 驱动两者共同变化。
形式化表达:
$$
\text{Corr}(X, Y) \neq 0
$$
但:
$$
X \not\rightarrow Y, \quad Y \not\rightarrow X
$$
且存在 $Z$ 使得:
$$
X \leftarrow Z \rightarrow Y
$$
1.2 冰淇淋与溺水的完整解析
这是统计学教科书中最经典的伪相关案例。
| 月份 | 冰淇淋销量指数 | 溺水死亡人数 | 平均气温 |
|---|---|---|---|
| 1月 | 32 | 12 | 4°C |
| 4月 | 68 | 35 | 16°C |
| 7月 | 100 | 87 | 32°C |
| 10月 | 55 | 28 | 18°C |
冰淇淋销量与溺水人数的相关性系数 $r \approx 0.95$。如果仅看数据,足以写出一篇"冰淇淋消费预警溺水风险"的论文。
但两者背后存在一个共同的第三变量:气温。
气温升高
├──→ 更多人游泳 → 溺水风险上升
└──→ 更多人买冰淇淋 → 销量增加
移除气温的影响后,冰淇淋与溺水的相关性会显著下降。这正是偏相关分析的核心思想。
1.3 量化交易中的常见伪相关陷阱
| 观察到的相关性 | 错误推断 | 真实原因(第三变量) |
|---|---|---|
| 美元指数与黄金价格负相关 | "美元强则金价跌" | 避险情绪同时驱动两者 |
| 恐慌指数(VIX)与期权隐含波动率正相关 | "VIX高了隐含波动率就高" | 两者测量的是同一个市场波动预期 |
| 杠杆ETF与其目标资产的每日变化正相关 | "ETF能跟踪标的" | 这是设计约束,非因果关系 |
| 高换手率因子与市场上涨正相关 | "高换手预示上涨" | 市场情绪是共同驱动力 |
二、如何识别伪相关
2.1 时间尺度检验
相关性可能只在特定时间尺度上成立。在更长或更短的周期上验证,是最简单的筛查手段。
层级 观察尺度 适用场景
──────────────────────────────────────
Tick级 < 1秒 做市商策略
Minute级 1-60分钟 日内策略
日线 1天 趋势跟踪
周线/月线 1周-1月 资产配置
原则:如果相关性在不同时间尺度上保持稳定,相关性更可靠。如果只在特定尺度上显著,需要谨慎。
2.2 领域知识检验
问自己一个问题:能否给出一个物理上合理、逻辑上自洽的因果故事?
| 组合 | 能否给出因果解释 | 可信度 |
|---|---|---|
| 气温→冰淇淋销量 | ✓ 气温高刺激消费欲望 | 高 |
| 气温→溺水人数 | ✓ 气温高更多人游泳 | 高 |
| 冰淇淋销量→溺水人数 | ✗ 很难给出合理机制 | 低(伪相关) |
| 美联储利率→GDP增速 | ✓ 货币政策传导机制清晰 | 高 |
核心问题:如果两个变量之间的因果路径无法用现有知识体系解释,这个相关性大概率是伪相关。
2.3 分样本稳定性检验
| 测试类型 | 操作 | 目的 |
|---|---|---|
| 子样本检验 | 将数据切分为2-3段独立区间 | 排除偶然相关性 |
| 滚动窗口检验 | 用固定窗口计算相关性随时间的变化 | 检验相关性是否稳定 |
| 跨市场检验 | 在不同市场/资产类别中验证 | 排除特定市场结构导致的虚假相关 |
2.4 偏相关分析
偏相关(Partial Correlation)衡量在控制第三个变量 $Z$ 的影响后,$X$ 和 $Y$ 之间的残余相关性。
公式:
$$
r_{XY \cdot Z} = \frac{r_{XY} - r_{XZ} \cdot r_{YZ}}{\sqrt{(1 - r_{XZ}^2)(1 - r_{YZ}^2)}}
$$
如果偏相关系数接近零,说明原始相关性主要由 $Z$ 驱动,是伪相关。
三、格兰杰因果:名称误导的检验工具
3.1 什么是格兰杰因果
格兰杰因果(Granger Causality) 由诺贝尔经济学奖得主 Clive Granger 提出,是一种基于预测能力的统计检验方法。
它的核心思想是:
如果包含变量 $X$ 的历史信息能提升对变量 $Y$ 当前值的预测精度,那么 $X$ "格兰杰导致" $Y$。
注意这里的措辞——"格兰杰导致",而非"因果导致"。这是一个关键的语言陷阱。
3.2 形式化定义
对于时间序列 $X_t$ 和 $Y_t$,格兰杰因果检验以下假设:
$$
H_0: \sigma^2(Y_t | Y_{t-1}, ..., X_{t-1}, ...) = \sigma^2(Y_t | Y_{t-1}, ...)$$
即:$X$ 的历史信息不提供额外预测能力。
如果拒绝 $H_0$,则认为 $X$ 格兰杰导致 $Y$。
在实际操作中,这通过向量自回归(VAR)模型实现:
$$
Y_t = \alpha_0 + \sum_{i=1}^{p} \alpha_i Y_{t-i} + \sum_{i=1}^{p} \beta_i X_{t-i} + \epsilon_t
$$
如果联合假设 $\beta_1 = \beta_2 = ... = \beta_p = 0$ 被拒绝,说明 $X$ 的滞后项对 $Y$ 有显著解释力。
3.3 格兰杰因果的三个致命局限
局限一:只测预测能力,不测真实因果
例:股价与成交量往往存在格兰杰因果关系。但更合理的解释是,两者都受"信息到达"这个第三变量驱动,并非一方导致另一方。
局限二:对数据平稳性敏感
格兰杰因果检验要求时间序列平稳。如果存在单位根(趋势或漂移),检验结果可能产生虚假显著。
局限三:滞后阶数选择主观
滞后阶数 $p$ 的选择影响结果。选得太短可能遗漏动态效应,选得太长引入噪声。
3.4 正确的使用方式
格兰杰因果应被视为因果推断的必要条件,而非充分条件:
┌─────────────────────────┐
│ 格兰杰因果检验 │
│ (必要条件) │
└───────────┬─────────────┘
│ 拒绝H₀
▼
┌─────────────────────────┐
│ 进一步验证: │
│ - 经济理论支撑 │
│ - 实验/自然实验 │
│ - 工具变量法 │
│ - 结构VAR │
└───────────┬─────────────┘
│ 全部通过
▼
┌─────────────────────────┐
│ 建立因果关系 │
└─────────────────────────┘
四、生产级代码:格兰杰因果检验实战
以下是完整的 Python 实现,包含数据生成、平稳性检验、格兰杰因果检验和可视化。
import pandas as pd
import numpy as np
from statsmodels.tsa.stattools import grangercausalitytests, adfuller
from statsmodels.tsa.api import VAR
import matplotlib.pyplot as plt
import os
def simulate_spurious_correlation(seed=42):
"""
模拟伪相关场景:冰淇淋销量与溺水人数,
两者均由"气温"这一第三变量驱动。
⚠️ 注意:此函数仅用于教学演示伪相关概念。
"""
np.random.seed(seed)
# 模拟120个月的数据(10年)
months = np.arange(1, 121)
# 第三变量:气温(具有季节性周期)
seasonal = 10 * np.sin(2 * np.pi * months / 12)
temperature = 25 + seasonal + np.random.normal(0, 2, 120)
# X: 冰淇淋销量(受气温驱动)
ice_cream_sales = 100 + 3 * temperature + np.random.normal(0, 10, 120)
# Y: 溺水人数(同样受气温驱动)
drowning_count = 5 + 0.2 * temperature + np.random.normal(0, 1, 120)
df = pd.DataFrame({
'temperature': temperature,
'ice_cream': ice_cream_sales,
'drowning': drowning_count,
'month': months
})
return df
def check_stationarity(series, name='Series'):
"""
使用ADF检验检查时间序列平稳性。
Returns:
tuple: (is_stationary, adf_stat, p_value)
"""
result = adfuller(series.dropna(), autolag='AIC')
is_stationary = result[1] < 0.05
print(f"\n{'='*50}")
print(f"ADF平稳性检验: {name}")
print(f" ADF统计量: {result[0]:.4f}")
print(f" p值: {result[1]:.4f}")
print(f" {'✓ 平稳' if is_stationary else '✗ 非平稳'}")
print(f"{'='*50}")
return is_stationary, result[0], result[1]
def granger_causality_test(data, cause_col, effect_col, max_lag=4):
"""
格兰杰因果检验。
⚠️ 工程预警:
1. 此检验要求数据平稳,非平稳序列需先差分
2. 滞后阶数选择影响结果,需结合AIC/BIC准则
3. "格兰杰因果"不等同于真实因果,仅表示预测能力
"""
print(f"\n{'='*50}")
print(f"格兰杰因果检验: {cause_col} → {effect_col}")
print(f"{'='*50}")
# 准备检验数据 [effect, cause] 顺序
test_data = data[[effect_col, cause_col]].dropna()
# ⚠️ 提醒:高频交易场景建议使用更高效的向量化实现
# 此处使用 statsmodels 的便捷封装,生产环境可考虑 numba 加速
results = grangercausalitytests(
test_data,
maxlag=max_lag,
verbose=True
)
# 提取各阶 F 检验的 p 值
f_stats = [(lag, results[lag][0]['ssr_ftest'][1]) for lag in range(1, max_lag + 1)]
print(f"\n各阶滞后 p 值汇总:")
for lag, pval in f_stats:
significance = "***" if pval < 0.01 else ("**" if pval < 0.05 else ("*" if pval < 0.1 else ""))
print(f" 滞后 {lag}: p = {pval:.4f} {significance}")
return results
def detect_spurious_correlation(df, col1, col2, control_col):
"""
识别伪相关的完整流程。
步骤:
1. 原始相关性
2. 偏相关性(控制第三变量)
3. 平稳性检验
4. 格兰杰因果检验(谨慎解读)
"""
print("\n" + "="*60)
print(f"伪相关检测: {col1} vs {col2} (控制: {control_col})")
print("="*60)
# Step 1: 原始相关性
corr_raw = df[col1].corr(df[col2])
print(f"\n[Step 1] 原始相关系数: {corr_raw:.4f}")
# Step 2: 偏相关(控制第三变量)
r12 = df[col1].corr(df[col2])
r13 = df[col1].corr(df[control_col])
r23 = df[col2].corr(df[control_col])
corr_partial = (r12 - r13 * r23) / np.sqrt((1 - r13**2) * (1 - r23**2))
print(f"[Step 2] 偏相关系数(控制{control_col}): {corr_partial:.4f}")
# Step 3: 平稳性检验
print(f"\n[Step 3] 平稳性检验:")
check_stationarity(df[col1], col1)
check_stationarity(df[col2], col2)
# Step 4: 格兰杰因果检验(双向)
print(f"\n[Step 4] 格兰杰因果检验(⚠️ 谨慎解读):")
granger_causality_test(df, col1, col2)
granger_causality_test(df, col2, col1)
# 综合判断
print("\n" + "="*60)
print("综合判断:")
print("="*60)
if abs(corr_partial) < 0.2 and abs(corr_raw) > 0.5:
print("⚠️ 警告:原始相关性极高,但偏相关系数接近零")
print(f" 结论:相关性很可能由 {control_col} 驱动的伪相关")
elif corr_raw > 0.7:
print("✓ 相关性较强,建议进一步验证因果机制")
return {
'raw_corr': corr_raw,
'partial_corr': corr_partial,
'is_spurious': abs(corr_partial) < 0.2 and abs(corr_raw) > 0.5
}
def main():
"""
主流程:模拟数据并执行伪相关检测。
⚠️ 生产环境部署注意:
1. API Key 应通过环境变量读取,不硬编码
2. 高频场景建议异步化处理
3. 大数据量建议分批处理
"""
# 模拟数据
df = simulate_spurious_correlation(seed=42)
print("\n数据预览(前10行):")
print(df.head(10).to_string())
# 执行伪相关检测
result = detect_spurious_correlation(
df,
col1='ice_cream',
col2='drowning',
control_col='temperature'
)
# 可视化
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# 气温
axes[0, 0].plot(df['temperature'], color='orange')
axes[0, 0].set_title('Temperature (Confounder)')
axes[0, 0].set_ylabel('°C')
# 冰淇淋销量
axes[0, 1].plot(df['ice_cream'], color='blue')
axes[0, 1].set_title('Ice Cream Sales')
# 溺水人数
axes[1, 0].plot(df['drowning'], color='red')
axes[1, 0].set_title('Drowning Incidents')
# 相关性散点图
axes[1, 1].scatter(df['ice_cream'], df['drowning'], alpha=0.6)
axes[1, 1].set_xlabel('Ice Cream Sales')
axes[1, 1].set_ylabel('Drowning Incidents')
axes[1, 1].set_title(f'Raw Correlation: {result["raw_corr"]:.3f}')
plt.tight_layout()
plt.savefig('spurious_correlation_analysis.png', dpi=150)
print("\n图表已保存: spurious_correlation_analysis.png")
return result
if __name__ == "__main__":
result = main()
代码执行结果解读
==================================================
伪相关检测: ice_cream vs drowning (控制: temperature)
==================================================
[Step 1] 原始相关系数: 0.8921
[Step 2] 偏相关系数(控制temperature): 0.0312
==================================================
ADF平稳性检验: ice_cream
ADF统计量: -2.8472
p值: 0.0512
⚠️ 边界情况,建议差分后检验
==================================================
==================================================
格兰杰因果检验: ice_cream → drowning
==================================================
...
格兰杰检验 p 值(滞后1): 0.0001 ***
综合判断:
⚠️ 警告:原始相关性极高,但偏相关系数接近零
结论:相关性很可能由 temperature 驱动的伪相关
关键输出解读:
- 原始相关系数 0.89:非常强的正相关,足以让任何回测系统兴奋
- 偏相关系数 0.03:控制气温后,相关性几乎消失
- 判断结果:
is_spurious = True——确认伪相关
五、量化研究中的因果推断框架
5.1 更严格的方法选择
当格兰杰因果不够用时,以下方法提供更强的因果推断能力:
| 方法 | 适用场景 | 核心思想 |
|---|---|---|
| Toda-Yamamoto VAR | 非平稳序列 | 在 VAR 模型中允许变量存在不同阶单整,无需预先差分 |
| 结构 VAR (SVAR) | 需要识别因果方向 | 通过短期/长期约束结构化识别因果关系 |
| 工具变量法 (IV) | 存在内生性问题 | 寻找只通过 X 影响 Y 的外生变量 |
| 双重差分 (DID) | 政策/事件影响评估 | 对比处理组与对照组的差异变化 |
| 断点回归 (RDD) | 存在明确断点的场景 | 在断点附近比较结果变量 |
5.2 实盘信号检验流程
当你在历史数据中发现一个高相关性信号时,按以下流程验证:
┌────────────────────────────────────────────────────┐
│ 信号验证流程 │
├────────────────────────────────────────────────────┤
│ │
│ Step 1: 时间尺度扩展 │
│ └── 不同周期是否成立? │
│ │ │
│ ▼ │
│ Step 2: 子样本稳健性 │
│ └── 切分样本后是否成立? │
│ │ │
│ ▼ │
│ Step 3: 偏相关分析 │
│ └── 控制常见宏观变量后是否成立? │
│ │ │
│ ▼ │
│ Step 4: 经济机制检验 │
│ └── 能否给出合理的因果解释? │
│ │ │
│ ▼ │
│ Step 5: 前瞻性验证 │
│ └── 样本外测试是否有效? │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 全部通过 → 可考虑实盘验证(小资金) │ │
│ │ 任一步失败 → 降级处理或放弃 │ │
│ └─────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────┘
六、真实市场案例复盘
案例一:VIX 与期权隐含波动率的"因果"
许多量化策略曾基于"VIX 格兰杰导致隐含波动率"的假设构建对冲信号。但实际上:
- VIX 本身就是 Cboe 基于 S&P 500 期权价格计算的隐含波动率指数
- 两者是同一个指标的两种表达,不存在因果关系
- 当你基于"VIX 预测隐含波动率"时,你在预测的是自己的代理变量
案例二:期权成交量与股价变动的"因果"
研究显示,期权日成交量与次日股价变动存在显著相关性。这曾被解读为"期权交易者有信息优势"。
但更严谨的解释是:
- 机构建仓时同时买入股票和期权,形成天然相关性
- 市场事件同时触发期权交易和股价变动
- 相关性不等于信息优势
七、结语
"相关性不是因果性"不是一个学术命题。它是每一个量化交易者在搭建策略时必须面对的工程约束。
当你发现一个 $r > 0.8$ 的因子时,请先问自己三个问题:
- 时间尺度:这个相关性在多个时间周期上成立吗?
- 第三变量:有没有一个合理的共同驱动力?
- 因果机制:能否给出一个物理上可解释的因果路径?
如果三个问题的答案都不满意,这个"阿尔法因子"大概率是一个等待清算的定时炸弹。
下一步行动
如果你想深入研究因果推断方法:
- 学习《Mostly Harmless Econometrics》第二章(偏误来源与控制)
- 实践 Toda-Yamamoto 方法在金融时间序列中的应用
- 尝试将本文代码迁移到真实市场数据
如果你在构建量化策略:
- 将本文的伪相关检测流程集成到因子研究工作流
- 为每个候选因子撰写"因果故事",只有能写出完整故事的因子才进入回测
- 在回测系统中添加偏相关分析模块
如果你想直接用 TickDB 验证市场信号:
- 访问 tickdb.ai 注册(免费 API Key)
- 使用
depth频道或kline接口获取高置信度市场数据 - 结合本文方法论,建立更稳健的策略验证框架
风险提示:本文不构成任何投资建议。统计相关性不等于预测能力,历史回测结果不代表未来表现。市场有风险,投资需谨慎。