4、数据清洗与标准化:缺失值处理、异常值检测、字段名统一、数据类型转换

数据清洗,说白了就是给数据「洗澡」。

我做了这么多年量化,见过太多人一上来就建模,结果模型跑出来全是垃圾。为什么?因为数据本身就有问题。你想想看,如果源数据里一堆空值、异常值,字段名还五花八门,那后面的分析全是白搭。

这一节,我就把我在跨市场行情同步中踩过的坑,一个一个说清楚。

4.1 缺失值处理:别让空值毁了你的策略

跨市场数据同步,最头疼的就是缺失值。不同交易所的开盘时间不一样,节假日也不一样。比如A股春节休市,港股只休一天,美股正常交易。这时候你拿到的数据,天然就有缺口。

我个人习惯,先判断缺失值的类型:

  • 完全随机缺失:比如网络抖动丢了一笔数据。这种可以直接删掉,或者用前后值填充。
  • 随机缺失:比如某个品种流动性差,偶尔没成交。这种可以用插值法。
  • 非随机缺失:比如节假日休市。这种千万不能乱填,必须保留空值或者标记为NaN。

核心原则:不要为了填充而填充。非交易时间的数据,填了就是造假。

我在项目中遇到过,有人把美股圣诞节的空值用前一天的数据填充,结果策略在回测里表现很好,实盘直接崩了。为什么?因为圣诞节那天根本没有交易,你填的数据是「幻觉」。

常用的处理方法:

  • 向前填充(ffill):用上一个非空值填充。适合处理网络抖动导致的短暂缺失。
  • 向后填充(bfill):用下一个非空值填充。适合处理数据延迟到达的情况。
  • 线性插值:用前后两个值的平均值填充。适合处理连续型数据,比如价格。
  • 直接删除:如果缺失比例小于5%,直接删掉行,简单粗暴。
import pandas as pd
import numpy as np

# 示例:处理缺失值
df = pd.DataFrame({
    'time': pd.date_range('2024-01-01', periods=10, freq='D'),
    'close': [100, 101, np.nan, 103, 104, np.nan, 106, 107, 108, 109]
})

# 向前填充(适合网络抖动)
df['close_ffill'] = df['close'].ffill()

# 线性插值(适合价格序列)
df['close_interpolate'] = df['close'].interpolate()

# 标记非交易时间(保留空值)
df['is_trading'] = df['close'].notna().astype(int)

小技巧:我建议你保留原始空值列,同时生成一个「是否交易」的标记列。这样后续分析时,你可以自由选择是否使用填充后的数据。

4.2 异常值检测:揪出那些「离谱」的数据

异常值,就是数据里的「害群之马」。比如某只股票突然涨了1000%,或者价格变成负数。这些数据如果不处理,模型会学出奇怪的东西。

我曾经在同步港股数据时,发现某天的收盘价是0.01港元。一开始以为是数据源错了,后来查了一下,原来是那天发生了「乌龙指」交易。这种数据必须剔除,否则你的均线系统会被拉偏。

常用的检测方法:

  • 3σ原则:超出均值±3倍标准差的数据,视为异常。适合正态分布的数据。
  • 箱线图法(IQR):超出Q1-1.5*IQR或Q3+1.5*IQR的数据,视为异常。适合非正态分布。
  • 固定阈值法:比如价格不能为负,涨跌幅不能超过50%。适合有明显业务约束的场景。
# 示例:使用IQR检测异常值
def detect_outliers_iqr(series):
    Q1 = series.quantile(0.25)
    Q3 = series.quantile(0.75)
    IQR = Q3 - Q1
    lower_bound = Q1 - 1.5 * IQR
    upper_bound = Q3 + 1.5 * IQR
    return (series < lower_bound) | (series > upper_bound)

# 标记异常值
df['is_outlier'] = detect_outliers_iqr(df['close'])

# 处理异常值:替换为NaN,或者用前后值填充
df.loc[df['is_outlier'], 'close'] = np.nan
df['close'] = df['close'].interpolate()

注意:不要机械地使用统计方法。比如在重大利好公告后,股价跳空高开20%,这在统计上是异常值,但在业务上是合理的。你需要结合市场事件来判断。

4.3 字段名统一:别让命名毁了你的代码

跨市场数据同步,最烦的就是字段名不统一。A股叫「close」,港股叫「closing_price」,美股叫「Adj Close」。如果你不统一,后面写代码时全是if-else,维护起来想哭。

我个人习惯,在数据入库之前,先做一次字段映射。把所有数据源的字段名,统一成一套标准命名。

标准字段名 A股(tushare) 港股(yahoo) 美股(alpha vantage)
open open Open 1. open
high high High 2. high
low low Low 3. low
close close Close 4. close
volume vol Volume 5. volume
# 示例:字段名映射
field_mapping = {
    'open': ['open', 'Open', '1. open'],
    'high': ['high', 'High', '2. high'],
    'low': ['low', 'Low', '3. low'],
    'close': ['close', 'Close', '4. close', 'Adj Close'],
    'volume': ['vol', 'Volume', '5. volume']
}

def standardize_columns(df, mapping):
    """统一字段名"""
    reverse_map = {}
    for standard_name, aliases in mapping.items():
        for alias in aliases:
            reverse_map[alias] = standard_name
    df = df.rename(columns=reverse_map)
    return df

# 使用
df_standard = standardize_columns(df_raw, field_mapping)

建议:把字段映射表单独存成一个JSON或YAML文件。这样以后新增数据源时,只需要改配置文件,不用改代码。

4.4 数据类型转换:别让字符串坑了你

数据类型不对,是新手最容易犯的错误。比如价格字段是字符串类型,你直接去做数学运算,结果全是拼接字符串。或者日期字段是object类型,你没法做时间序列分析。

我记得有一次,我同事跑了一个策略,回测收益高得离谱。后来一查,发现他把「成交量」字段当成了字符串,计算时直接拼接,导致成交量变成了天文数字。嗯,这种错误我犯过一次就再也不会犯了。

常见的转换场景:

  • 字符串转数值:用pd.to_numeric(),注意处理千分位逗号。
  • 字符串转日期:用pd.to_datetime(),指定format参数提高效率。
  • 整数转浮点:避免整数除法导致精度丢失。
  • 分类数据转category:节省内存,提高groupby速度。
# 示例:数据类型转换
df = pd.DataFrame({
    'date': ['2024-01-01', '2024-01-02', '2024-01-03'],
    'close': ['100.50', '101.20', '102.30'],
    'volume': ['1,000,000', '1,200,000', '1,100,000']
})

# 字符串转日期
df['date'] = pd.to_datetime(df['date'])

# 字符串转数值(处理千分位逗号)
df['close'] = pd.to_numeric(df['close'])
df['volume'] = pd.to_numeric(df['volume'].str.replace(',', ''))

# 检查数据类型
print(df.dtypes)

注意:转换时一定要处理错误。比如某个字段里混入了字母,直接转换会报错。建议使用errors='coerce'参数,把无法转换的值变成NaN,然后再处理。

4.5 本章知识体系

下面这张图,是我梳理的数据清洗与标准化流程。你可以把它当作一个检查清单,每次处理数据时对照着来。

数据清洗与标准化流程 原始数据 缺失值处理 异常值检测 字段名统一 数据类型转换 ffill/bfill 插值/删除 3σ/IQR 固定阈值 映射表 统一命名 to_numeric to_datetime

数据清洗这件事,看起来琐碎,但它是整个量化交易的基础。你想想看,如果数据都是脏的,后面的回测、策略、实盘,全都是在沙子上盖楼。

我个人建议,每次拿到新数据源,先花20%的时间做清洗和标准化。这20%的时间,能省掉后面80%的调试时间。别问我怎么知道的,都是血泪教训。


专注资料整理