2. 数据清洗实战
数据清洗这件事,说白了就是跟脏数据打架。我做了这么多年项目,真正花在调模型上的时间,其实远没有花在洗数据上的多。你想想看,垃圾进垃圾出,模型再花哨也没用。
这一章咱们就聚焦两个最头疼的问题:缺失值和异常值。我会把我在实战中踩过的坑、用过的招,都摊开来跟你聊聊。
核心观点:数据清洗不是机械操作,每一步都要问自己——这个缺失是随机的还是系统性的?这个异常是噪声还是信号?带着业务理解去做清洗,比任何算法都重要。
缺失值处理:别急着删,先看看情况
拿到数据第一件事,我习惯先跑个 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,高维复杂数据用 DBSCAN。
- 最后验证:处理前后跑一遍模型,看效果有没有提升。如果处理完反而变差了,说明方法选错了。
我的习惯:每次做数据清洗,我都会保留原始数据的副本,并且记录每一步操作。这样如果后面发现模型有问题,还能回溯排查。说白了,数据清洗是个迭代过程,不是一次性工作。