4. 数据清洗与预处理:缺失值处理、异常值检测、去重、时间戳对齐、数据类型转换

实时交易数据在进入风控特征引擎之前,必须经过严格的清洗与预处理。原始数据流中常包含传感器空值、网络抖动导致的重复记录、不同交易所的时间偏差以及字段类型不匹配等问题。本章将系统讲解五大核心预处理步骤,确保特征计算的准确性与时效性。

核心原则: 实时风控场景下,预处理必须在毫秒级完成。优先使用向量化操作与流式处理框架(如 Kafka Streams、Flink),避免逐行循环。

4.1 缺失值处理

交易数据缺失通常由行情中断、数据源延迟或字段映射失败引起。处理策略需根据缺失率与业务含义选择:

策略 适用场景 实时实现方式 风险提示
前向填充 价格、成交量等连续序列 df.ffill() 或流式状态变量 长时间缺失会累积偏差
后向填充 回测或批处理场景 df.bfill() 实时中不可用(引入未来信息)
固定值填充 订单簿深度、标记位 df.fillna(0) 或 -1 可能掩盖异常状态
插值法 高频 tick 数据 df.interpolate() 计算开销较大,需控制窗口
丢弃行 缺失率 > 80% 或关键字段 df.dropna() 可能丢失交易信号
import pandas as pd
import numpy as np

# 模拟实时 tick 数据
tick_data = pd.DataFrame({
    'timestamp': pd.date_range('2025-01-01', periods=10, freq='100ms'),
    'bid': [100.1, np.nan, 100.3, np.nan, np.nan, 100.6, 100.7, np.nan, 100.9, 101.0],
    'ask': [100.2, 100.2, np.nan, 100.4, 100.5, np.nan, 100.8, 100.8, np.nan, 101.1],
    'volume': [100, 200, np.nan, 150, np.nan, 300, 250, np.nan, 180, 220]
})

# 前向填充 + 限制最大填充步数(防止陈旧数据)
tick_data['bid'] = tick_data['bid'].ffill(limit=3)
tick_data['ask'] = tick_data['ask'].ffill(limit=3)
tick_data['volume'] = tick_data['volume'].fillna(0)  # 成交量缺失视为 0
避坑: 实时风控中禁止使用 bfill() 或全局插值,它们会引入未来数据,导致回测过拟合与实盘失效。始终使用流式友好的前向填充或状态缓存。

4.2 异常值检测

异常值可能来自行情错误、网络闪断或恶意操纵。实时检测需兼顾灵敏度与计算效率,常用方法如下:

  • Z-Score 法: 适用于正态分布特征,实时维护滚动均值和标准差,阈值通常设为 3 或 3.5。
  • IQR 法: 对偏态分布鲁棒,使用滚动分位数(Q1, Q3),异常定义为超出 [Q1 - 1.5*IQR, Q3 + 1.5*IQR]。
  • 固定阈值法: 根据业务规则硬编码,如单笔成交量超过历史均值 10 倍、买卖价差为负。
  • 孤立森林(Isolation Forest): 适合多维异常检测,但实时场景需使用增量版本或降采样。
def detect_anomalies_zscore(series, window=20, threshold=3.0):
    """滚动 Z-Score 异常检测"""
    rolling_mean = series.rolling(window=window).mean()
    rolling_std = series.rolling(window=window).std()
    z_scores = (series - rolling_mean) / rolling_std
    return z_scores.abs() > threshold

# 检测 bid 价格异常
anomaly_mask = detect_anomalies_zscore(tick_data['bid'], window=5, threshold=2.5)
tick_data['bid_anomaly'] = anomaly_mask
print(tick_data[tick_data['bid_anomaly']])
实战建议: 将异常值标记为 NaN 或替换为前向有效值,而非直接丢弃。丢弃会破坏时间序列的连续性,影响后续特征如动量、波动率的计算。
实时异常值检测流程 原始数据流 Z-Score 滚动检测 IQR 分位数检测 固定业务阈值 异常聚合 (投票/加权) 标记/替换/告警 说明:实时场景下建议组合 2-3 种方法,通过投票机制降低误报率。Z-Score 适合价格类,IQR 适合成交量类。

4.3 去重

重复数据通常由网络重传、消息队列重复消费或交易所冗余推送引起。去重策略需区分精确去重与近似去重:

场景 去重键 方法 实时注意事项
同一交易所 tick timestamp + symbol + exchange df.drop_duplicates(subset=[...]) 使用布隆过滤器减少内存
多源聚合数据 全局唯一 ID (UUID) Redis 或内存 Set 去重 设置 TTL 防止内存泄漏
窗口聚合结果 窗口起始时间 + 分组键 幂等写入(覆盖模式) 确保窗口边界对齐
# 精确去重示例
tick_data = tick_data.drop_duplicates(subset=['timestamp', 'bid', 'ask'], keep='last')

# 流式场景使用布隆过滤器(需安装 pybloom_live)
from pybloom_live import ScalableBloomFilter
bloom = ScalableBloomFilter(mode=ScalableBloomFilter.SMALL_SET_GROWTH)
def is_duplicate(tick_id):
    if tick_id in bloom:
        return True
    bloom.add(tick_id)
    return False

4.4 时间戳对齐

不同数据源的时间戳可能存在微秒级偏差,对齐是计算跨市场价差、订单簿同步等特征的前提。对齐策略包括:

  • 最近邻对齐: 将每个 tick 对齐到最近的整秒/整毫秒边界,使用 pd.DatetimeIndex.floor()。
  • 线性插值对齐: 在固定频率时间点上插值,适用于高频连续数据。
  • 异步消息队列对齐: 使用 Kafka 时间戳提取器或 Flink 的 Watermark 机制。
# 对齐到 100ms 时间桶
tick_data['timestamp'] = pd.to_datetime(tick_data['timestamp'])
tick_data.set_index('timestamp', inplace=True)
aligned = tick_data.resample('100ms').ffill().bfill()  # 先前向后向后填充边界

# 多源对齐示例:合并两个交易所的 tick
exchange_a = tick_data[['bid']].rename(columns={'bid': 'bid_a'})
exchange_b = tick_data[['ask']].rename(columns={'ask': 'ask_b'})
merged = pd.merge_asof(exchange_a, exchange_b, left_index=True, right_index=True, direction='nearest', tolerance=pd.Timedelta('50ms'))
性能提示: merge_asof 是实时对齐的利器,时间复杂度 O(n log n),远优于笛卡尔积。在流式框架中,可使用 IntervalJoin 或 TemporalJoin 实现类似效果。

4.5 数据类型转换

原始数据流中的字段类型常与特征计算要求不匹配,需在预处理阶段统一转换:

原始类型 目标类型 转换方法 常见问题
字符串数字 float64 pd.to_numeric(..., errors='coerce') 非数字字符导致 NaN
int64 float64 df['col'].astype('float64') 大整数精度丢失
Unix 毫秒时间戳 datetime64[ns] pd.to_datetime(..., unit='ms') 时区未指定
object 分类 category df['col'].astype('category') 类别过多导致内存不降反升
bool int8 df['col'].astype('int8') 缺失值无法转换
# 完整数据类型转换流水线
def convert_dtypes_pipeline(df):
    # 时间戳统一
    if 'timestamp' in df.columns:
        df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms', utc=True)
    # 价格字段转为 float64
    price_cols = ['bid', 'ask', 'last', 'high', 'low']
    for col in price_cols:
        if col in df.columns:
            df[col] = pd.to_numeric(df[col], errors='coerce')
    # 成交量转为 float32(节省内存)
    if 'volume' in df.columns:
        df['volume'] = df['volume'].astype('float32')
    # 标记位转为 int8
    flag_cols = ['is_buy', 'is_anomaly']
    for col in flag_cols:
        if col in df.columns:
            df[col] = df[col].astype('int8')
    return df

tick_data = convert_dtypes_pipeline(tick_data)
print(tick_data.dtypes)
实时风控数据预处理流水线 缺失值处理 ffill / fillna 异常值检测 Z-Score / IQR 去重 布隆过滤器 时间戳对齐 resample / merge_asof 类型转换 astype / to_numeric 原始数据流 清洗后特征 说明:五个阶段按顺序执行,每个阶段输出作为下一阶段输入。实际部署时可将流水线封装为 Kafka Streams 拓扑或 Flink 算子链。

4.6 综合实战:实时清洗流水线

将上述步骤整合为一个可复用的清洗函数,适用于 Kafka 消息的逐条处理:

def realtime_cleanse(msg: dict, state: dict) -> dict:
    """
    单条消息清洗函数,适用于流式处理
    state 维护滚动统计量(均值、标准差、布隆过滤器等)
    """
    # 1. 类型转换
    msg['bid'] = pd.to_numeric(msg.get('bid'), errors='coerce')
    msg['ask'] = pd.to_numeric(msg.get('ask'), errors='coerce')
    msg['volume'] = float(msg.get('volume', 0))

    # 2. 缺失值处理(前向填充)
    if pd.isna(msg['bid']):
        msg['bid'] = state.get('last_bid', 0.0)
    if pd.isna(msg['ask']):
        msg['ask'] = state.get('last_ask', 0.0)
    state['last_bid'] = msg['bid']
    state['last_ask'] = msg['ask']

    # 3. 异常值检测(滚动 Z-Score)
    state['bid_history'] = state.get('bid_history', []) + [msg['bid']]
    if len(state['bid_history']) > 20:
        state['bid_history'].pop(0)
    if len(state['bid_history']) >= 5:
        mean = np.mean(state['bid_history'])
        std = np.std(state['bid_history'])
        if std > 0 and abs(msg['bid'] - mean) / std > 3.0:
            msg['bid_anomaly'] = 1
            msg['bid'] = state['last_bid']  # 替换为前向值
        else:
            msg['bid_anomaly'] = 0

    # 4. 去重(布隆过滤器)
    tick_id = f"{msg['timestamp']}_{msg['symbol']}"
    if tick_id in state.get('bloom', set()):
        return None  # 丢弃重复
    state.setdefault('bloom', set()).add(tick_id)

    # 5. 时间戳对齐(向下取整到 100ms)
    ts = pd.to_datetime(msg['timestamp'], unit='ms')
    msg['timestamp_aligned'] = ts.floor('100ms').value // 10**6

    return msg
生产级建议: 上述函数可直接嵌入 pandas_udf(PySpark)或 ProcessFunction(Flink)。对于超高频场景(> 10万 tick/秒),建议用 Cython 或 Numba 编译关键路径,或使用 Rust/Python 桥接库(如 Polars)。