数据清洗与预处理:缺失值处理、异常值检测、去重与对齐、数据标准化与中性化

做量化因子研究,有个残酷的现实我得先告诉你——80%的时间都花在数据清洗上。这不是夸张,我早期做策略回测时,经常因为数据没洗干净,跑出漂亮的曲线,实盘却一塌糊涂。后来我学乖了:数据不洗,因子白做。

这一章,咱们就把数据清洗的四个核心环节掰开揉碎讲清楚。你想想看,如果原始数据里全是坑,再牛的模型也是白搭。

核心原则:清洗不是目的,让数据真实反映市场规律才是。宁可丢掉可疑数据,也别让脏数据污染你的因子。

缺失值处理:别让空值毁了你的因子

缺失值,说白了就是数据表里那些空着的格子。股票停牌、财报延迟、数据源抽风,都会导致缺失。我见过最离谱的一次,某数据商把整月的交易量字段全丢了,我跑出来的因子居然还能赚钱——后来发现是假信号。

处理缺失值,常用的方法就这几种:

  • 直接删除:简单粗暴,适合缺失比例小于5%的情况。但要注意,删除可能导致样本偏差。
  • 均值/中位数填充:用该字段的均值或中位数补上。适合数值型数据,但会降低方差。
  • 前向/后向填充:用上一个或下一个有效值填充。时间序列数据常用,比如用昨天的收盘价补今天的。
  • 插值法:线性插值、多项式插值等。更平滑,但计算量大。
  • 模型预测:用其他字段预测缺失值。精度高,但容易过拟合。

我个人习惯的做法是:先看缺失比例。超过30%的字段,直接扔掉。5%-30%的,用插值法。低于5%的,用中位数填充。为什么用中位数而不是均值?因为金融数据常有极端值,均值会被带偏。

import pandas as pd
import numpy as np

# 示例:处理缺失值
df = pd.DataFrame({
    'close': [100, 102, np.nan, 105, 107, np.nan, 110],
    'volume': [10000, 12000, 11000, np.nan, 13000, 14000, 15000]
})

# 查看缺失比例
print(df.isnull().sum() / len(df))

# 前向填充 + 线性插值组合
df['close'] = df['close'].fillna(method='ffill').interpolate()
df['volume'] = df['volume'].fillna(df['volume'].median())

print(df)

避坑指南:我曾经用均值填充停牌日的收益率数据,结果因子信号全乱了。停牌日的收益率应该是0,不是均值。记住:业务逻辑优先于统计方法

异常值检测:揪出那些捣乱的数据点

异常值,就是那些明显偏离正常范围的数据。比如某只股票突然涨了1000%,或者成交量突然少了几个零。这些数据点会严重扭曲你的因子计算。

检测异常值,我常用的方法有:

  1. 3σ原则:假设数据服从正态分布,超过均值±3倍标准差的就是异常值。简单,但金融数据很少是正态分布。
  2. 箱线图法:用四分位数来判定。超过Q1-1.5IQR或Q3+1.5IQR的就是异常值。更稳健,不受极端值影响。
  3. Z-score方法:计算每个数据点的Z分数,超过阈值(通常3)的视为异常。适合大样本。
  4. DBSCAN聚类:基于密度的聚类算法,孤立点就是异常值。适合多维数据。

嗯,这里要注意:异常值不一定是错误值。比如2015年股灾时的暴跌,那是真实的市场行为,不是数据错误。所以检测出来后,要结合业务判断是剔除还是保留。

from scipy import stats

# 示例:Z-score检测异常值
data = [100, 102, 98, 105, 1000, 101, 99, 103, 97, 104]
z_scores = np.abs(stats.zscore(data))
threshold = 3

outliers = np.where(z_scores > threshold)[0]
print(f"异常值索引: {outliers}")

# 处理:用中位数替换
data_clean = data.copy()
for idx in outliers:
    data_clean[idx] = np.median(data)

警告:千万别一刀切地删除所有异常值。我在做高频因子时,发现很多异常值其实是市场微观结构的真实反映。删除它们,反而丢失了重要信息。建议先标记,再逐条判断。

去重与对齐:让数据排好队

数据去重,听起来简单,做起来坑多。同一个股票代码,可能因为数据源不同出现重复行;同一个时间戳,可能因为合并操作出现多条记录。

去重的核心逻辑:确定唯一标识。对于股票数据,通常是(股票代码,时间戳)这个组合。对于财报数据,可能是(股票代码,报告期)。

# 示例:去重
df = pd.DataFrame({
    'code': ['000001', '000001', '000002', '000002'],
    'date': ['2024-01-01', '2024-01-01', '2024-01-01', '2024-01-02'],
    'close': [100, 101, 200, 201]  # 000001在2024-01-01有重复
})

# 按(code, date)去重,保留第一条
df_dedup = df.drop_duplicates(subset=['code', 'date'], keep='first')
print(df_dedup)

数据对齐,则是把不同频率、不同来源的数据统一到同一个时间轴上。比如日度收益率和月度财报数据,需要对齐到同一个日期。

我常用的对齐方式:

  • 左对齐:以某个数据集的时间轴为准,其他数据向左对齐。
  • 右对齐:以某个数据集的时间轴为准,其他数据向右对齐。
  • 内连接:只保留所有数据集都有的时间点。
  • 外连接:保留所有时间点,缺失的用NaN填充。

做因子计算时,我一般用右对齐。为什么?因为因子值通常是用历史数据计算的,右对齐能确保你用的是「当时能拿到」的数据,避免未来信息。

关键点:对齐时一定要注意时间戳的精度。日度数据用日期对齐,分钟数据用时间戳对齐。我曾经因为时区问题,把美股和A股的数据对齐错了,跑出来的因子相关性全是错的。

数据标准化与中性化:让因子站在同一起跑线

标准化,就是把不同量纲的数据转换到同一个尺度。比如市盈率(PE)和市净率(PB),数值范围差很多,直接比较没意义。

常用的标准化方法:

方法 公式 适用场景
Z-score标准化 (x - μ) / σ 数据近似正态分布
Min-Max标准化 (x - min) / (max - min) 数据有明确边界
Rank标准化 排序后映射到[0,1] 数据有极端值
分位数标准化 映射到标准正态分位数 数据分布未知

我个人偏爱Rank标准化。金融数据极端值多,Z-score容易被带偏。Rank标准化只看相对顺序,稳健得多。

# 示例:Rank标准化
def rank_standardize(series):
    ranks = series.rank()
    return (ranks - 1) / (len(ranks) - 1)

df['pe_rank'] = rank_standardize(df['pe'])
df['pb_rank'] = rank_standardize(df['pb'])

中性化,则是剔除因子中与某些变量(如市值、行业)的相关性。说白了,就是让因子「纯净」一些,不要混入其他因素的影响。

最常见的两种中性化:

  • 市值中性化:剔除因子中与市值相关的部分。因为大市值股票和小市值股票天然有差异。
  • 行业中性化:剔除因子中与行业相关的部分。不同行业的估值水平不同。

做法很简单:用因子值对市值(或行业哑变量)做回归,取残差作为中性化后的因子。

import statsmodels.api as sm

# 示例:市值中性化
# 假设df包含因子值factor和市值market_cap
X = sm.add_constant(df['market_cap'])
y = df['factor']
model = sm.OLS(y, X).fit()
df['factor_neutral'] = model.resid  # 残差就是中性化后的因子

经验之谈:中性化不是万能的。过度中性化会抹掉因子的真实信号。我一般先做行业中性化,再看是否需要市值中性化。如果因子本身就和市值高度相关,那可能这个因子本身就是「市值因子」的代理变量,中性化后就没意义了。

本章知识体系

下面这张图,帮你把数据清洗与预处理的整体流程串起来:

数据清洗与预处理流程 原始数据 缺失值处理 删除/填充/插值 异常值检测 3σ/箱线图/Z-score 去重与对齐 唯一标识/时间对齐 标准化与中性化 Rank/市值/行业 清洗后数据 因子计算

数据清洗不是一劳永逸的事。每次接入新数据源、每次调整因子计算逻辑,都要重新走一遍这个流程。我现在的习惯是:把清洗流程写成函数,每次跑因子前自动执行。这样既省时间,又不容易漏掉步骤。

好了,这一章的内容就到这里。数据清洗虽然枯燥,但它是因子生产的基石。你想想看,如果地基没打好,上面盖的房子再漂亮,也经不起风雨。

专注资料整理