第 2 章 数据预处理基础:缺失值填充、异常值清洗、标准化与归一化

做异常检测,数据预处理是死活绕不开的一步。我见过不少团队,模型选得够先进,调参也舍得花时间,结果数据没洗干净,上线效果惨不忍睹。原因很简单——异常检测本质上是在找“少数派”,如果数据本身脏了,模型根本分不清哪些是真实异常、哪些是采集错误。

这一章我们就把预处理的三板斧练扎实:缺失值怎么填、异常值怎么洗、怎么把数据拉到相同的尺度上。每一点我都会把项目里踩过的坑抖出来。

数据预处理流程框架 原始数据 缺失值填充 异常值清洗 标准化/归一化 均值/中位数 前向/后向填充 插值法 3σ 原则 IQR 法 分位数箱线图 Z-Score 标准化 Min-Max 归一化 RobustScaler 清洗后数据 输入异常检测模型 缺失值 异常值 标准化

2.1 缺失值填充——别让空值毁了你的模型

我刚开始做时序异常检测时,遇到过一批传感器数据,缺了将近15%的读数。当时偷懒用了简单的删除行处理,结果模型在缺失时段频繁报警。后来一查,那些缺失本身就是传感器间歇性故障造成的,删除反而丢失了“故障模式”的信息。所以碰到缺失值,第一反应不是删,而是问自己:这个缺失代表的含义是什么?

常见的填充策略

方法 适用场景 风险
均值/中位数填充 数值型、缺失率 < 5% 压缩方差,弱化异常信号
前向填充 (ffill) 时序数据,短时间缺失 连续缺失太长会引入偏差
后向填充 (bfill) 时序数据,开始部分缺失 可能使用未来信息,需小心
线性插值 近似线性变化的数据 非线性段插值失真
KNN 填充 多维特征相关性强 计算成本高,异常样本影响大
我的习惯:对于时序异常检测任务,我通常会先用 ffill + bfill 组合,再对剩余空值做线性插值。如果缺失比例超过 20%,我会打一个“缺失标志位”作为额外特征,让模型自己学。
import pandas as pd
import numpy as np

# 示例:时序数据填充
df = pd.DataFrame({
    'timestamp': pd.date_range('2024-01-01', periods=10, freq='H'),
    'value': [12.3, np.nan, 13.1, np.nan, np.nan, 11.9, 12.7, np.nan, 13.0, 12.5]
})

# 前向填充,再后向填充
df['value_filled'] = df['value'].ffill().bfill()
# 残差做线性插值
df['value_interp'] = df['value'].interpolate(method='linear')
print(df)

2.2 异常值清洗——别把“好异常”当成噪声洗掉

这里有个非常容易翻车的地方。异常值不等于脏数据。你想,我们做异常检测,目标就是找异常,结果预处理阶段一刀把异常全砍了,模型还学什么?

正确的思路是:先把明显的采集错误(比如传感器超出量程的时间戳值)清洗掉,然后保留那些“可疑”的点,等模型去判。我一般把清洗分为两级:

  • 一级清洗(硬阈值):比如温度传感器不可能低于 -50°C,压力不可能为负,读数为 NaN 或 Inf 的字段。这些直接删除或替换为空值。
  • 二级清洗(统计方法):针对余下的数据用 3σ 或 IQR 标记,但标记后不删除,而是作为候选异常列表,供后续规则引擎和模型联合判断。

两种主流方法对比

方法 原理 适用分布 陷阱
3σ 原则 均值 ± 3×标准差外的点为异常 近似正态分布 异常本身会拉偏均值和标准差,导致漏检
IQR 法 Q1 - 1.5×IQR 以下 或 Q3 + 1.5×IQR 以上 任意分布,更稳健 对长尾分布可能过宽,需要调整倍数
我曾经踩过的坑:有一次我直接用 3σ 处理流量数据,结果发现异常点被“自我遮蔽”了——因为异常值本身把标准差撑大了,导致很多真正的离群点反而落在 3σ 区间内。后来我改用修正的 Z-Score(用中位数和 MAD),效果好了很多。
import numpy as np

# IQR 异常值标记
def mark_outliers_iqr(data, multiplier=1.5):
    q1 = np.percentile(data, 25)
    q3 = np.percentile(data, 75)
    iqr = q3 - q1
    lower = q1 - multiplier * iqr
    upper = q3 + multiplier * iqr
    return (data < lower) | (data > upper)

# 修正 Z-Score (MAD)
def mark_outliers_modified_zscore(data, threshold=3.5):
    median = np.median(data)
    mad = np.median(np.abs(data - median))
    modified_z = 0.6745 * (data - median) / mad
    return np.abs(modified_z) > threshold

2.3 标准化与归一化——统一量纲才能比大小

异常检测模型里很多算法都依赖距离或梯度,比如 KNN、孤立森林、基于聚类的方法。如果一个特征的量级是 0~100000,另一个是 0~1,距离计算会被大数完全主导。说白了就是“大数欺负小数”。

标准化和归一化不是一回事,选错了会出问题。我直接给结论:

  • 标准化 (Z-Score):减去均值除标准差,输出服从 N(0,1)。适合数据本身有正有负、大致正态的场景。孤立森林、One-Class SVM 都推荐用这个。
  • 归一化 (Min-Max):线性压缩到 [0,1]。适合有明确上下界的场景,比如像素值、百分比。注意如果新来的数据超界,需要做剪裁。
  • RobustScaler:用中位数和 IQR 做中心缩放。对异常值更鲁棒。我个人在异常检测预处理阶段用这个最多,因为原始数据里肯定还留着未被清洗的异常点,RobustScaler 不会被带偏。

什么时候一定要用标准化,不能用归一化?

嗯,举个例子。你在做多变量时序异常检测,特征包括 CPU 使用率(0%~100%)和内存消耗(GB 级)。如果用 Min-Max 归一化,压缩到 [0,1] 后,两个特征在零点附近的行为会完全扭曲,因为 Min-Max 对分布形状没有任何概念。标准化可以保留原始分布的峰度和偏度信息,模型更容易学出异常模式。

个人经验:在任何异常检测 pipeline 里,我都会先画一下各特征分布的直方图。偏态严重的最好先做 log 变换再标准化,这样尾部的异常点不会被过度压缩。这件事花不了 5 分钟,但回报巨大。
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler

data = np.array([[100, 0.01],
                 [200, 0.02],
                 [300, 0.03],
                 [5000, 0.50],
                 [10000, 1.00]])

# 对比三种方法
scaler_std = StandardScaler()
scaler_minmax = MinMaxScaler()
scaler_robust = RobustScaler()

print("StandardScaler:\n", scaler_std.fit_transform(data))
print("MinMaxScaler:\n", scaler_minmax.fit_transform(data))
print("RobustScaler:\n", scaler_robust.fit_transform(data))

2.4 关于处理顺序,我建议这样做

你可能会问:这三步是按顺序做,还是可以打乱?我的建议是固定的流程:

  1. 先清洗异常值(硬阈值 + 统计标记,但只删除明显错误,保留可疑点)。
  2. 再填充缺失值(否则缺失值会影响异常检测的统计指标)。
  3. 最后做标准化/归一化(彻底清洗后再缩放,避免缩放参数被脏数据污染)。

你想想看,如果在填充缺失值之前就用 Z-Score 标准化,均值会被缺失值拉偏,填充效果会更差。这是链条效应,一步错步步错。

2.5 一个完整的预处理模板

下面是我在项目中常用的函数骨架,你可以直接拿去改:

def preprocess_pipeline(df, numeric_cols, outlier_method='iqr',
                        fill_method='linear', scale_method='robust'):
    # Step 1: 清洗异常值(硬阈值 + IQR 标记,删除硬错误)
    for col in numeric_cols:
        # 硬阈值示例:去掉负值或无穷大
        df = df[(df[col] >= 0) & (np.isfinite(df[col]))]
    
    # Step 2: 标记但保留候选异常,进入后续模型
    # 实际项目里我会保留 outlier_mask 作为特征
    
    # Step 3: 填充缺失值
    df[numeric_cols] = df[numeric_cols].interpolate(method=fill_method, limit_direction='both')
    
    # Step 4: 标准化
    scaler = RobustScaler() if scale_method == 'robust' else StandardScaler()
    df[numeric_cols] = scaler.fit_transform(df[numeric_cols])
    
    return df, scaler

一句话总结:数据预处理不是在“修复”数据,而是在“翻译”数据——把原始嘈杂信号翻译成模型能听懂的语言。缺失值、异常值、尺度差异,都是语言不通的噪声。翻译做得好,异常检测模型才能真正发挥功力。