2. 数据清洗基础:处理缺失值、异常值、重复值的基本方法

做因子挖掘这些年,我踩过最大的坑,不是模型选错了,而是数据没洗干净就往上怼。你想想看,一堆脏数据进去,再牛的算法也是白搭。今天咱们就聊聊数据清洗最基础的三件事:缺失值、异常值、重复值。说白了,就是让数据变得「能用」。

核心观点:数据清洗不是可有可无的预处理步骤,而是因子挖掘的基石。脏数据会导致因子失效、过拟合、甚至策略亏损。我个人习惯,拿到数据第一件事不是看分布,而是先做清洗。

2.1 缺失值处理:别让「空」坑了你

缺失值在金融数据里太常见了。停牌、节假日、数据源故障,都会导致空值。我在项目中遇到过,某次回测收益曲线漂亮得不行,结果发现是因为缺失值被自动填充成了0,相当于凭空多了很多「低价买入」的机会——这显然是假的。

2.1.1 缺失值检测

先看看数据到底缺了多少。用Pandas很简单:

import pandas as pd
import numpy as np

# 模拟一份日频因子数据
df = pd.DataFrame({
    'date': pd.date_range('2024-01-01', periods=10, freq='D'),
    'factor_a': [1.2, np.nan, 1.5, 1.3, np.nan, 1.7, 1.1, np.nan, 1.4, 1.6],
    'factor_b': [0.5, 0.6, np.nan, 0.7, 0.8, np.nan, 0.9, 1.0, np.nan, 1.1],
    'return': [0.01, 0.02, -0.01, 0.03, 0.00, 0.02, 0.01, -0.02, 0.03, 0.01]
})

# 检测缺失值
print(df.isnull().sum())
print(f"缺失比例: \n{df.isnull().mean() * 100:.2f}%")

输出结果会告诉你每列缺了多少。我个人习惯,缺失比例超过50%的因子,直接扔掉——补了也是猜,不如不用。

2.1.2 缺失值处理方法

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

方法 适用场景 注意事项
删除法 缺失比例极低(<5%) 会损失样本量,小心时间序列断裂
均值/中位数填充 因子分布较稳定 会降低方差,影响因子区分度
前向/后向填充 时间序列数据 适合停牌、节假日等场景
插值法 数据有趋势性 线性插值简单,但别过度拟合
模型预测 缺失值较多且重要 计算量大,小心引入偏差

举个例子,对于日频因子数据,我一般这样处理:

# 方法1:前向填充(适合停牌场景)
df['factor_a_ffill'] = df['factor_a'].fillna(method='ffill')

# 方法2:线性插值(适合连续缺失)
df['factor_b_interp'] = df['factor_b'].interpolate(method='linear')

# 方法3:行业均值填充(适合截面数据)
# 假设有行业分类
industry_mean = df.groupby('industry')['factor_a'].transform('mean')
df['factor_a_industry'] = df['factor_a'].fillna(industry_mean)

我的经验:金融时间序列数据,优先用前向填充。因为停牌后复牌,因子值通常延续停牌前的状态。千万别用全局均值填充,那会把不同时间点的特征混在一起。

2.2 异常值处理:别让「极端」带偏了

异常值在因子数据里很要命。一个极端值,就能把相关系数从0.3拉到0.6,或者从显著变成不显著。我曾经因为没处理异常值,导致一个因子在回测里表现极好,实盘却一塌糊涂——后来发现是某只股票在数据里出现了100倍市盈率(数据录入错误)。

2.2.1 异常值检测方法

常用的检测方法有这几种:

  • Z-score法:假设数据正态分布,|Z|>3视为异常。适合对称分布的数据。
  • IQR法:用四分位距,超出Q1-1.5*IQR或Q3+1.5*IQR视为异常。对偏态分布更鲁棒。
  • MAD法:中位数绝对偏差,比Z-score更抗异常值干扰。
  • 领域知识法:比如市盈率不可能为负,换手率不可能超过100%。

代码实现:

def detect_outliers_iqr(series):
    Q1 = series.quantile(0.25)
    Q3 = series.quantile(0.75)
    IQR = Q3 - Q1
    lower = Q1 - 1.5 * IQR
    upper = Q3 + 1.5 * IQR
    return (series < lower) | (series > upper)

def detect_outliers_zscore(series, threshold=3):
    z = np.abs((series - series.mean()) / series.std())
    return z > threshold

# 示例
df['factor_a_outlier_iqr'] = detect_outliers_iqr(df['factor_a'])
df['factor_a_outlier_z'] = detect_outliers_zscore(df['factor_a'])

2.2.2 异常值处理方法

检测到异常值后,怎么处理?我一般按这个优先级来:

  1. 确认数据源:如果是录入错误,直接修正或删除。
  2. 截尾处理(Winsorize):把异常值替换为边界值。这是我最常用的方法。
  3. 缺失化处理:把异常值设为NaN,然后用缺失值方法处理。
  4. 保留但标记:如果异常值本身可能是信号(比如黑天鹅事件),可以单独标记。
from scipy.stats.mstats import winsorize

# 截尾处理:将上下1%的极端值替换为边界值
df['factor_a_winsor'] = winsorize(df['factor_a'], limits=[0.01, 0.01])

# 或者手动实现
def winsorize_series(series, lower=0.01, upper=0.01):
    q_low = series.quantile(lower)
    q_high = series.quantile(1 - upper)
    return series.clip(lower=q_low, upper=q_high)

注意:截尾处理会改变数据分布,尤其是尾部特征。如果你研究的因子本身依赖极端值(比如动量因子中的极端收益),截尾可能会破坏因子逻辑。这种情况下,我建议先保留异常值,单独分析其影响。

2.3 重复值处理:别让「复制」骗了你

重复值在因子数据里相对少见,但一旦出现,后果很严重。重复的样本会放大某些特征的影响,导致因子权重失真。我记得有一次,某只股票因为数据源重复导入,在回测中出现了两次相同的日频数据,结果该股票的因子贡献被高估了一倍。

2.3.1 重复值检测

重复值分两种:完全重复(所有列都一样)和部分重复(关键列一样)。对于因子数据,我们通常关注「时间+股票代码」这个组合是否唯一。

# 检测完全重复行
print(f"完全重复行数: {df.duplicated().sum()}")

# 检测基于关键列的重复
# 假设有股票代码和日期
# df['stock_id'] = ['000001', '000001', '000002', ...]
# df['date'] = ...
# print(f"关键列重复行数: {df.duplicated(subset=['stock_id', 'date']).sum()}")

# 查看重复的具体数据
# df[df.duplicated(subset=['stock_id', 'date'], keep=False)].sort_values(['stock_id', 'date'])

2.3.2 重复值处理方法

处理重复值,原则很简单:保留一条,删除其余。但保留哪条有讲究:

  • 保留第一条:适合数据按时间顺序排列,第一条最原始。
  • 保留最后一条:适合数据有更新,最后一条是最新版本。
  • 聚合处理:如果重复值有细微差异(比如不同数据源),可以取均值或中位数。
# 删除完全重复行
df_clean = df.drop_duplicates()

# 基于关键列删除,保留第一条
df_clean = df.drop_duplicates(subset=['stock_id', 'date'], keep='first')

# 如果有细微差异,聚合处理
# df_agg = df.groupby(['stock_id', 'date']).agg({
#     'factor_a': 'mean',
#     'factor_b': 'median',
#     'return': 'first'
# }).reset_index()

避坑指南:我曾经在处理日频数据时,发现某只股票在同一天出现了两条记录,一条是正常交易数据,一条是复权调整数据。这种情况下不能简单删除,需要根据业务逻辑判断保留哪条。我的建议是:先搞清楚重复的原因,再决定处理方式。

2.4 数据清洗的整体流程

说了这么多,咱们把整个流程串起来。下面这张图是我做数据清洗时的标准流程:

数据清洗标准流程 原始数据 缺失值处理 异常值处理 重复值处理 清洗后数据 删除/填充/插值 检测/截尾/标记 去重/聚合 先处理缺失值 再处理异常值 最后去重

这个流程我用了好几年,基本没出过问题。顺序很重要:先处理缺失值,再处理异常值,最后去重。为什么?因为缺失值填充后可能会产生新的异常值,而异常值处理后又可能产生重复值(比如截尾后多个值变成同一个边界值)。

2.5 实战中的注意事项

最后,分享几个我在实战中总结的经验:

  • 别一次性清洗所有数据。因子数据往往是分批获取的,每批数据可能有不同的质量问题。我习惯每获取一批数据,就做一次清洗。
  • 保留清洗日志。记录你删了多少行、填充了多少值、截尾了多少异常。这样回测出问题时,能快速定位是不是清洗环节出了问题。
  • 清洗参数要可复现。比如截尾的上下限、填充的方法,最好写成配置文件。这样换数据源或更新数据时,能保持一致的处理逻辑。
  • 别过度清洗。有些「异常值」其实是真实的市场行为,比如涨跌停导致的极端收益。盲目清洗会丢失重要信息。

总结一下:数据清洗是因子挖掘的第一步,也是最容易被忽视的一步。缺失值、异常值、重复值,这三个问题处理好了,你的因子就成功了一半。剩下的,就是特征工程和模型的事了。

专注资料整理