第四章:多因子数据预处理——去极值、标准化与中性化
数据预处理,说白了就是给原始数据“洗澡”。
我刚开始做量化那会儿,拿到数据就直接往模型里塞。结果呢?回测曲线漂亮得不行,实盘一跑就崩。后来才发现,是几个极端值把模型带偏了。嗯,从那以后,我再也不敢跳过预处理这步了。
今天咱们聊聊三个核心步骤:去极值、标准化、中性化。这三步做好了,因子数据才能“干干净净”地进模型。
一、去极值:别让“异常值”毁了你的模型
你想想看,如果某个股票突然因为乌龙指,收益率拉了100倍。这个数据进模型,模型会怎么想?它会觉得“哇,这个因子好厉害”,然后拼命给这个股票配权重。结果呢?下一期它就跌回去了。
去极值,就是把这些“离谱”的数据拉回来。我个人习惯用两种方法:
1.1 MAD法(绝对中位差法)
MAD法比标准差法更稳健。为什么?因为标准差会被极端值本身影响,而中位数不会。我曾在处理A股小市值因子时遇到过,标准差法把正常值都砍掉了,换成MAD法就没事。
具体做法:
- 计算因子值的中位数
median - 计算每个值与中位数的绝对偏差
|x - median| - 取这些偏差的中位数,得到
MAD - 设定阈值(通常3倍或5倍MAD),超出则替换为边界值
import numpy as np
def mad_winsorize(series, n=5):
"""
MAD法去极值
n: 阈值倍数,我一般用5
"""
median = np.median(series)
mad = np.median(np.abs(series - median))
upper = median + n * mad
lower = median - n * mad
return np.clip(series, lower, upper)
# 举个例子
data = np.array([1,2,3,4,5,100])
clean = mad_winsorize(data)
print(clean) # 100被拉回到上边界附近
1.2 百分位法
百分位法更直观:直接砍掉头尾一定比例的数据。比如我设定1%和99%分位数,超出部分就替换成边界值。
def percentile_winsorize(series, lower=0.01, upper=0.99):
"""
百分位法去极值
"""
lo = np.percentile(series, lower * 100)
hi = np.percentile(series, upper * 100)
return np.clip(series, lo, hi)
二、标准化:让不同因子站在同一起跑线
去完极值,数据还是“各说各话”。有的因子在0~1之间,有的在-1000~1000之间。模型会天然偏向数值大的因子——这不公平。
标准化就是解决这个问题的。我常用两种:
2.1 Z-score标准化
把数据变成均值为0、标准差为1的分布。公式很简单:z = (x - mean) / std
def zscore_normalize(series):
"""
Z-score标准化
注意:要去完极值再用
"""
return (series - np.mean(series)) / np.std(series)
Z-score的优点是保留了数据的相对大小关系。缺点是它假设数据大致对称——如果偏态严重,标准化后还是会有问题。
2.2 Min-Max标准化
把数据压缩到[0,1]区间:scaled = (x - min) / (max - min)
def minmax_normalize(series):
"""
Min-Max标准化
"""
return (series - np.min(series)) / (np.max(series) - np.min(series))
什么时候用哪个?
- Z-score:适合大多数情况,尤其是后续要做回归或相关性分析时
- Min-Max:适合因子值有明确上下界,或者你要用神经网络时(激活函数喜欢0~1输入)
我个人习惯:做多因子选股时用Z-score,做深度学习因子时用Min-Max。
三、中性化:剔除“不该有的”干扰
这一步很多人会忽略,但我觉得它最关键。
你想想看,如果一个因子在银行股上普遍高,在科技股上普遍低。那这个因子选出来的股票,到底是因子本身好,还是因为行业暴露?
中性化就是把这些“系统性偏差”剔除掉。主要做两种:
3.1 行业中性化
把因子值减去行业均值。这样每个行业内部的因子值就“公平”了。
def industry_neutralize(df, factor_col, industry_col):
"""
行业中性化:减去行业均值
df: 包含因子和行业标签的DataFrame
"""
means = df.groupby(industry_col)[factor_col].transform('mean')
return df[factor_col] - means
3.2 市值中性化
市值对因子的影响也很大。大市值股票和小市值股票,很多因子的分布都不一样。
做法是用因子值对市值做回归,取残差作为中性化后的因子:
import statsmodels.api as sm
def market_cap_neutralize(factor, log_market_cap):
"""
市值中性化:回归取残差
"""
X = sm.add_constant(log_market_cap)
model = sm.OLS(factor, X).fit()
return model.resid
四、完整的预处理流水线
把上面三步串起来,就是我的日常操作:
def preprocess_pipeline(factor_series, industry_labels, log_mkt_cap):
"""
完整预处理流水线
1. MAD法去极值
2. Z-score标准化
3. 行业+市值联合中性化
"""
# 第一步:去极值
clean = mad_winsorize(factor_series, n=5)
# 第二步:标准化
scaled = zscore_normalize(clean)
# 第三步:中性化(联合回归)
X = pd.get_dummies(industry_labels, drop_first=True)
X['log_mkt_cap'] = log_mkt_cap
X = sm.add_constant(X)
model = sm.OLS(scaled, X).fit()
neutralized = model.resid
return neutralized
记住这个顺序:
去极值 → 标准化 → 中性化
顺序不能乱。先中性化再去极值,会把中性化后的残差又搞出极端值。先标准化再去极值,标准化本身会被极端值带偏。
嗯,这个顺序是我踩过坑之后才确定的。
五、实战中的几个小细节
| 步骤 | 常见问题 | 我的建议 |
|---|---|---|
| 去极值 | 阈值设太严,好数据被砍 | 先用可视化看分布,再定阈值 |
| 标准化 | 忘记保存均值和标准差 | 训练集和测试集要用同一套参数 |
| 中性化 | 行业分类太细或太粗 | A股用申万一级行业,28个刚刚好 |
最后说一句:预处理不是“做完就完事”的。每次换数据、换时间窗口,都要重新跑一遍。我见过有人把去年的标准化参数直接用到今年——结果今年市场风格变了,因子全废了。
数据预处理,说白了就是给因子“卸妆”。卸干净了,模型才能看到真实的脸。