3、数据清洗与预处理:缺失值处理、异常值检测、数据标准化与中性化
各位同学,咱们直接进入正题。数据清洗这步,说白了就是给原始数据「洗澡」。你想想看,从交易所拿到的数据,那叫一个脏——缺胳膊少腿的、突然蹦出个离谱值的、量纲不统一的……这些数据要是直接扔进模型,结果绝对让你怀疑人生。
我个人习惯把数据预处理分成四步走:缺失值处理 → 异常值检测 → 数据标准化 → 中性化。每一步都有坑,咱们一个一个来拆。
3.1 缺失值处理:别让「空」坑了你
先问个问题:你拿到一份股票数据,发现某只股票某天的成交量是 NaN,你会怎么办?
我早期做回测时犯过一个低级错误——直接把有缺失的行删掉了。结果呢?回测曲线漂亮得不行,实盘一跑就崩。后来才发现,删除缺失值会导致幸存者偏差,尤其对于ST股或停牌股,你删掉的恰恰是最关键的信息。
常用的处理方法就三种:
- 直接删除:缺失比例极小(<5%)且随机分布时可用。但要注意,时间序列数据不能随便删行,会破坏时序连续性。
- 填充法:用均值、中位数、前向填充(ffill)、后向填充(bfill)。我个人偏爱用中位数,因为不受极端值影响。
- 插值法:线性插值、多项式插值。对于日频数据,线性插值就够用了。
重要原则:缺失值处理一定要在异常值检测之前做。为什么?因为异常值会拉偏均值,导致填充结果失真。
# 我个人常用的缺失值处理代码(Python)
import pandas as pd
import numpy as np
def handle_missing(df, method='median'):
"""处理缺失值"""
if method == 'median':
# 按行业分组填充中位数,避免行业偏差
df = df.groupby('industry').transform(lambda x: x.fillna(x.median()))
elif method == 'ffill':
df = df.fillna(method='ffill')
return df
3.2 异常值检测:揪出数据里的「害群之马」
异常值这东西,有时候是数据录入错误,有时候是真实的市场异动。比如某只股票突然涨了1000%,你说是异常还是正常?
我记得有一次做因子分析,发现某个因子的IC值高得离谱。排查了半天,原来是某只小盘股在数据里多了一个零——成交量从100万变成了1000万。这种错误不处理,整个因子就废了。
常用的检测方法:
| 方法 | 适用场景 | 阈值 | 我的建议 |
|---|---|---|---|
| 3σ原则 | 近似正态分布的数据 | 均值±3倍标准差 | 对极端值敏感,慎用 |
| IQR四分位距 | 偏态分布、有离群点 | Q1-1.5IQR 到 Q3+1.5IQR | 稳健,推荐使用 |
| 分位数截尾 | 所有类型 | 上下1%/5%分位数 | 简单粗暴,效果不错 |
小技巧:不要直接删除异常值,建议先做「缩尾处理」(Winsorize)——把超出阈值的值替换为阈值本身。这样既保留了样本量,又控制了极端值的影响。
# 缩尾处理示例
def winsorize_series(s, limits=[0.01, 0.01]):
"""上下1%缩尾"""
lower = s.quantile(limits[0])
upper = s.quantile(1 - limits[1])
return s.clip(lower, upper)
3.3 数据标准化:让不同量纲的数据「对话」
你想想看,市盈率(PE)可能是5到50,换手率可能是0.1%到10%,成交量可能是几百万到几十亿。这些数据直接放一起做回归,量纲大的变量会主导结果,这显然不合理。
标准化就是解决这个问题的。常用的两种:
- Z-score标准化:(x - μ) / σ。适用于数据近似正态分布的情况。我大部分时候都用这个。
- Min-Max归一化:(x - min) / (max - min)。把数据压缩到[0,1]区间。适合有明确上下界的数据。
注意:标准化时一定要按截面(cross-section)做,也就是每个时间点单独标准化。千万别把所有时间的数据混在一起算均值和标准差——那会引入未来信息,造成前视偏差(look-ahead bias)。
# 截面标准化:每个交易日单独做
def cross_sectional_zscore(df):
"""按日期分组做Z-score标准化"""
return df.groupby('date').transform(lambda x: (x - x.mean()) / x.std())
3.4 中性化处理:剥离你不想要的「风格暴露」
这一步是很多新手容易忽略的。什么叫中性化?说白了,就是把你因子中跟市值、行业等已知风险因子相关的部分剥离掉。
举个例子:你发现小市值股票的某个因子表现特别好。但问题是,这个「好」到底是因子本身的能力,还是小市值风格在起作用?如果不做中性化,你很可能把风格收益误认为是因子收益。
具体做法:
- 把因子值作为因变量,市值、行业等作为自变量,做线性回归。
- 取回归的残差作为中性化后的因子值。
- 残差就是因子中「纯」的部分,跟市值、行业无关。
核心思想:中性化不是要消除风格,而是要分离风格。你可以在组合构建时再主动决定要不要暴露某种风格,但因子本身必须是「干净」的。
# 市值中性化 + 行业中性化
import statsmodels.api as sm
def neutralize_factor(factor, market_cap, industry_dummies):
"""
factor: 原始因子值
market_cap: 对数市值
industry_dummies: 行业哑变量(one-hot编码)
"""
X = pd.concat([market_cap, industry_dummies], axis=1)
X = sm.add_constant(X)
model = sm.OLS(factor, X).fit()
return model.resid # 残差就是中性化后的因子
嗯,到这里数据清洗的四个核心步骤就讲完了。最后提醒一句:数据预处理没有银弹。不同的策略、不同的频率、不同的市场环境,适合的方法都不一样。我的习惯是先把一套标准流程跑通,然后根据回测结果反向调整——如果回测表现异常好,先别高兴,回去检查数据是不是处理错了。
我的经验:数据预处理花的时间,至少占整个量化研究时间的40%。别嫌多,这一步省下的时间,后面会加倍还给你。
公众号:蓝海资料掘金营,微信 deep3321