数据清洗实战:缺失值与异常值的处理

数据清洗这件事,说白了就是跟脏数据打架。我做了这么多年项目,真正花在调模型上的时间,其实远没有花在洗数据上的多。你想想看,垃圾进垃圾出,模型再花哨也没用。

这一章咱们就聚焦两个最头疼的问题:缺失值和异常值。我会把我在实战中踩过的坑、用过的招,都摊开来跟你聊聊。

核心观点:数据清洗不是机械操作,每一步都要问自己——这个缺失是随机的还是系统性的?这个异常是噪声还是信号?带着业务理解去做清洗,比任何算法都重要。

缺失值处理:别急着删,先看看情况

拿到数据第一件事,我习惯先跑个 df.isnull().sum() 看看缺失分布。但光看数量不够,你得知道缺失发生在哪些特征上、跟目标变量有没有关系。

方法一:直接删除

删除是最简单粗暴的方式,但用不好会出大问题。我个人习惯只在两种情况下用删除:

  • 缺失比例极低(比如 < 5%),且样本量足够大
  • 缺失完全随机,删除后不会引入偏差
# 删除含有缺失值的行
df_clean = df.dropna()

# 删除缺失比例超过50%的列
df_clean = df.dropna(thresh=len(df) * 0.5, axis=1)

我曾经踩过的坑:有一次做用户流失预测,直接删除了所有含缺失的行,结果模型在测试集上表现极差。后来才发现,缺失值大多来自高价值用户——他们信息填得少,但流失率低。删除操作直接把这类样本干掉了,模型自然学偏了。

方法二:均值/中位数/众数填充

这是最常用的方法,但不同场景要选不同的统计量:

数据类型 推荐填充方式 适用场景
连续型(正态分布) 均值填充 身高、体重、温度等
连续型(偏态分布) 中位数填充 收入、房价、点击量等
离散型/类别型 众数填充 性别、城市、产品类别等
# 均值填充
df['age'].fillna(df['age'].mean(), inplace=True)

# 中位数填充(收入通常偏态,用中位数更稳健)
df['income'].fillna(df['income'].median(), inplace=True)

# 众数填充
df['gender'].fillna(df['gender'].mode()[0], inplace=True)

小技巧:填充后最好加一列「是否曾被填充」的标识。比如 df['age_was_missing'] = df['age'].isnull().astype(int)。这样模型可以自己学习缺失模式,有时候这个标识本身就是一个强特征。

方法三:模型预测填充

当缺失值不是随机缺失,或者缺失比例较高时,用简单统计量填充会引入较大偏差。这时候我会用模型来预测缺失值。

具体做法:把待填充的特征当作目标变量,用其他完整特征训练一个模型,然后预测缺失部分。

from sklearn.ensemble import RandomForestRegressor

# 假设 'age' 有缺失,用其他特征预测
train = df[df['age'].notna()]
test = df[df['age'].isna()]

X_train = train.drop(['age', 'target'], axis=1)
y_train = train['age']
X_test = test.drop(['age', 'target'], axis=1)

model = RandomForestRegressor(n_estimators=100)
model.fit(X_train, y_train)
predicted_ages = model.predict(X_test)

df.loc[df['age'].isna(), 'age'] = predicted_ages

注意:模型填充也有风险。如果用来预测的特征本身质量不高,或者缺失特征与其他特征高度相关,填充结果可能会放大原有偏差。我一般会做交叉验证来评估填充效果。

异常值检测:找到那些「不对劲」的数据

异常值处理比缺失值更微妙。有些异常是噪声,需要剔除;有些异常是真正的信号,比如欺诈交易、设备故障前的异常波动。我的经验是:先检测,再判断,最后处理。

方法一:3σ 原则(拉依达准则)

这个方法假设数据服从正态分布,超出均值 ± 3 倍标准差的值视为异常。简单、直观,但局限性也很明显——对偏态分布不友好。

import numpy as np

def detect_outliers_3sigma(data, col):
    mean = data[col].mean()
    std = data[col].std()
    lower = mean - 3 * std
    upper = mean + 3 * std
    outliers = data[(data[col] < lower) | (data[col] > upper)]
    return outliers

# 使用示例
outliers = detect_outliers_3sigma(df, 'age')
print(f"检测到 {len(outliers)} 个异常值")

实战经验:3σ 方法对极端值非常敏感。如果数据本身就有几个极端大的值,均值和标准差都会被拉偏,导致正常值被误判为异常。我通常先用分位数看一眼分布,再决定是否用这个方法。

方法二:IQR 箱线图法

这个方法不依赖正态分布假设,用四分位数来定义异常边界。我个人更常用这个,因为它对偏态数据更稳健。

公式很简单:
下边界 = Q1 - 1.5 × IQR
上边界 = Q3 + 1.5 × IQR

def detect_outliers_iqr(data, col):
    Q1 = data[col].quantile(0.25)
    Q3 = data[col].quantile(0.75)
    IQR = Q3 - Q1
    lower = Q1 - 1.5 * IQR
    upper = Q3 + 1.5 * IQR
    outliers = data[(data[col] < lower) | (data[col] > upper)]
    return outliers

outliers = detect_outliers_iqr(df, 'income')
print(f"IQR方法检测到 {len(outliers)} 个异常值")
方法 优点 缺点
3σ 原则 计算简单,理论成熟 依赖正态分布,对极端值敏感
IQR 箱线图 不依赖分布假设,稳健 对样本量敏感,小样本时边界不稳定

方法三:DBSCAN 聚类检测

当数据维度较高,或者异常模式比较复杂时,我会用 DBSCAN。这个算法不需要预先指定聚类数量,能把密度低的点自动标记为异常。

from sklearn.cluster import DBSCAN
from sklearn.preprocessing import StandardScaler

# 标准化
scaler = StandardScaler()
X_scaled = scaler.fit_transform(df[['age', 'income', 'spend']])

# DBSCAN 聚类
dbscan = DBSCAN(eps=0.5, min_samples=5)
labels = dbscan.fit_predict(X_scaled)

# 标签为 -1 的点就是异常点
df['is_outlier_dbscan'] = (labels == -1).astype(int)
print(f"DBSCAN检测到 {df['is_outlier_dbscan'].sum()} 个异常值")

参数调优的坑:DBSCAN 的两个参数 eps 和 min_samples 非常敏感。我一般会用 K-distance 图来估计 eps 的合理范围。如果数据量很大,DBSCAN 的计算开销也不小,可以考虑先用采样跑一遍。

知识体系总览

下面这张图把本章的核心内容串起来了,你可以对照着回顾一下:

数据清洗核心流程 原始数据 缺失值处理 异常值检测 直接删除 统计量填充 模型预测填充 3σ 原则 IQR 箱线图 dropna() mean/median/mode RandomForest等 mean ± 3σ Q1-1.5IQR / Q3+1.5IQR 清洗后数据 关键原则 先理解业务含义 → 再选择方法 → 最后验证效果。没有万能方案,只有最适合的方案。

实战建议:怎么选?

说了这么多方法,你可能会问:到底该用哪个?我的建议是分三步走:

  1. 先看数据量:样本够多、缺失随机 → 删除;样本少、缺失有规律 → 填充。
  2. 再看分布:正态分布用 3σ,偏态分布用 IQR,高维复杂数据用 DBSCAN。
  3. 最后验证:处理前后跑一遍模型,看效果有没有提升。如果处理完反而变差了,说明方法选错了。

我的习惯:每次做数据清洗,我都会保留原始数据的副本,并且记录每一步操作。这样如果后面发现模型有问题,还能回溯排查。说白了,数据清洗是个迭代过程,不是一次性工作。