第四章:数据清洗与预处理——别让脏数据毁了你的策略
做量化投研这几年,我最大的感悟就是:数据决定上限,模型只是逼近这个上限。你想想看,再牛的Transformer,喂进去一堆垃圾,吐出来的还是垃圾。这一章,咱们就聊聊数据清洗那些绕不开的坑。
核心观点:数据预处理占整个投研流程的60%-80%时间,但决定了策略的生死。
4.1 缺失值处理——别急着删,先问问为什么
拿到数据第一件事,我习惯先跑个 df.isnull().sum()。但注意,缺失不是原罪,要看缺失的机制。
我在项目中遇到过一种情况:某只小盘股突然连续3天没有交易数据。一开始我以为是数据源问题,后来一查,原来是停牌了。这种缺失你要是直接删掉或者填充,策略逻辑就全乱了。
常见的缺失值处理策略
| 缺失类型 | 处理方法 | 适用场景 |
|---|---|---|
| 完全随机缺失 | 删除或均值填充 | 数据量大,缺失比例<5% |
| 随机缺失 | 插值法、KNN填充 | 时间序列数据 |
| 非随机缺失 | 单独建模处理 | 停牌、退市等特殊事件 |
我的习惯:金融数据优先用前向填充(ffill),因为市场信息是逐步释放的。用未来数据填充过去,那是作弊。
# 我个人最常用的缺失值处理流水线
import pandas as pd
import numpy as np
def clean_missing(df, method='ffill', limit=3):
"""
method: ffill / bfill / interpolate / drop
limit: 连续缺失最大容忍天数
"""
if method == 'ffill':
return df.fillna(method='ffill', limit=limit)
elif method == 'interpolate':
return df.interpolate(method='time', limit_direction='forward')
elif method == 'drop':
return df.dropna(thresh=len(df.columns) * 0.7) # 保留70%以上非空的行
4.2 异常值检测——那些让你亏钱的数据点
异常值在量化里分两种:一种是数据错误(比如价格突然跳了100倍),另一种是真实极端事件(比如2015年股灾)。
我曾经犯过一个错:用3σ原则把2015年6月的所有数据都标为异常然后删掉了。结果回测曲线漂亮得不行,实盘直接被打脸。为什么?因为我把真正的尾部风险给洗掉了。
避坑指南:异常值检测要分两步走——先识别技术异常(数据录入错误),再标记业务异常(极端行情)。前者要处理,后者要保留并单独分析。
常用的异常检测方法
- Z-Score法:适合正态分布数据,阈值一般取3
- IQR四分位法:适合偏态分布,用Q1-1.5IQR和Q3+1.5IQR
- 孤立森林:适合高维数据,我最近在因子分析里常用
def detect_outliers_iqr(series, factor=1.5):
Q1 = series.quantile(0.25)
Q3 = series.quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - factor * IQR
upper = Q3 + factor * IQR
return (series < lower) | (series > upper)
# 注意:金融数据用1.5倍太激进,我一般用3倍
4.3 数据类型转换——隐形的性能杀手
嗯,这里要注意。很多新手拿到数据直接开搞,结果跑起来慢得想砸电脑。我检查过他们的代码,发现日期列是字符串,价格列是object类型。
数据类型不对,不仅慢,还会算错。比如 '2024-01-01' 和 2024-01-01 在排序时结果完全不同。
| 原始类型 | 目标类型 | 转换方法 |
|---|---|---|
| 字符串日期 | datetime64 | pd.to_datetime() |
| object数值 | float64 | pd.to_numeric(errors='coerce') |
| int64 | float32 | 节省50%内存 |
小技巧:处理百万级数据时,把int64转成int32或float32,内存占用直接砍半。我有个策略原来跑要8G内存,优化后2G就够了。
4.4 时间序列对齐——不同频率数据的缝合术
做多因子模型时,你经常会遇到:日线数据、分钟数据、财报数据(季度)、宏观数据(月度)。这些数据频率不同,怎么对齐?
说白了,就是以最细粒度的时间戳为基准,把其他数据填充过来。我习惯用 pd.merge_asof,它比普通的merge更智能,能自动找到最近的时间点。
# 日线数据和财报数据对齐
daily_prices = pd.DataFrame({'date': pd.date_range('2024-01-01', periods=100, freq='D')})
quarterly_earnings = pd.DataFrame({
'date': pd.to_datetime(['2024-01-15', '2024-04-15', '2024-07-15']),
'eps': [1.2, 1.5, 1.8]
})
# 用asof对齐,每个交易日取最近一次财报数据
aligned = pd.merge_asof(daily_prices, quarterly_earnings, on='date', direction='backward')
注意:对齐时一定要考虑「未来信息泄露」。比如用季度数据时,财报发布日期和财报截止日期是两回事。我见过有人直接用截止日期对齐,结果用未来数据预测过去,回测曲线漂亮得离谱。
4.5 数据标准化与归一化——让不同量纲的因子公平竞争
你想想看,市盈率可能是5-50,换手率是0.1%-5%,市值是10亿-10000亿。如果不做标准化,模型会天然偏向数值大的特征。
我个人习惯分两步:先去极值,再标准化。去极值用MAD(中位数绝对偏差),比Z-Score更稳健。
| 方法 | 公式 | 输出范围 | 适用场景 |
|---|---|---|---|
| Z-Score标准化 | (x - μ) / σ | 无固定范围 | 数据近似正态分布 |
| Min-Max归一化 | (x - min) / (max - min) | [0, 1] | 有明确上下界 |
| Rank归一化 | 排序后映射到[0,1] | [0, 1] | 因子有异常值 |
def robust_standardize(series):
"""稳健标准化:用中位数和MAD代替均值和标准差"""
median = series.median()
mad = (series - median).abs().median()
# 防止除以0
mad = max(mad, 1e-8)
return (series - median) / (1.4826 * mad) # 1.4826使MAD与标准差一致
我的经验:做截面标准化时(比如每天对所有股票的因子做标准化),一定要按日期分组。如果你对整个时间序列做标准化,相当于用了未来信息,回测会严重高估。
知识体系总览
下面这张图是我自己梳理的数据预处理流程,每次做新策略前都会过一遍:
这张图我每次做新策略都会贴在墙上。你想想看,如果数据这关没过,后面所有模型工作都是白费。我见过太多团队花3个月调模型,最后发现是数据对齐出了问题。
总结一句话:数据清洗不是体力活,是策略的第一道风控。把这一步做扎实了,后面的建模工作会顺畅很多。