4. 数据清洗实战(上):处理缺失值、重复值与异常值
数据清洗这步,说实话,是建模过程中最枯燥但又最关键的一环。我见过太多项目,前期EDA做得漂亮,特征工程也花里胡哨,结果模型上线后效果一塌糊涂——回头一查,数据里全是坑。今天咱们就把这三个最常见的坑填上:缺失值、重复值、异常值。
核心观点:数据清洗不是机械地删删补补,而是基于业务理解做决策。你清洗的方式,直接决定了模型的上限。
4.1 缺失值处理:删还是补?这是个问题
缺失值在真实数据里太常见了。传感器故障、人工录入遗漏、数据源不完整——原因五花八门。我个人习惯,拿到数据第一件事就是看缺失率。
4.1.1 先摸清缺失的底细
用 df.isnull().sum() 快速扫一眼,再用 missingno 库可视化缺失模式。我遇到过最坑的一次,某个特征缺失率高达 70%,但业务方说「这个字段很重要」——结果模型跑出来,这个特征贡献度几乎为零,白白浪费计算资源。
import pandas as pd
import missingno as msno
# 快速查看缺失情况
print(df.isnull().sum() / len(df) * 100)
# 可视化缺失模式
msno.matrix(df)
我的经验:缺失率 < 5% 的特征,直接删除行或简单填充都行。缺失率 > 50% 的特征,除非业务上极其重要,否则我建议直接扔掉。
4.1.2 删除策略:什么时候该删?
删除是最简单粗暴的方法,但用不好会伤到模型。我一般分两种情况:
- 行删除(listwise deletion):缺失值占比很小(< 5%),且缺失是随机的。直接用
df.dropna()。 - 列删除:某个特征缺失率超过 70%,或者缺失模式与目标变量高度相关——这时候留着反而引入偏差。
注意:千万别在测试集上做行删除!测试集必须保持完整,否则你没法评估模型在真实场景下的表现。我曾经犯过这个错,结果模型上线后 F1 直接掉了 0.15。
4.1.3 填充策略:补上比删掉更聪明
大多数情况下,我们选择填充。常用的方法有这些:
| 填充方法 | 适用场景 | 代码示例 |
|---|---|---|
| 均值/中位数填充 | 数值型特征,数据分布较对称用均值,有偏态用中位数 | df['col'].fillna(df['col'].median()) |
| 众数填充 | 类别型特征 | df['col'].fillna(df['col'].mode()[0]) |
| 前向/后向填充 | 时间序列数据 | df.fillna(method='ffill') |
| KNN 填充 | 特征间相关性较强时 | from sklearn.impute import KNNImputer |
| 模型预测填充 | 缺失值对业务意义重大时 | 用随机森林等模型预测缺失值 |
我个人最常用的是中位数填充和 KNN 填充。中位数对异常值不敏感,KNN 能利用特征间的关系。但 KNN 计算量大,数据量超过 10 万行时我会慎用。
# 中位数填充
df['temperature'].fillna(df['temperature'].median(), inplace=True)
# KNN 填充
from sklearn.impute import KNNImputer
imputer = KNNImputer(n_neighbors=5)
df_imputed = pd.DataFrame(imputer.fit_transform(df), columns=df.columns)
4.2 重复值处理:别让数据「复制粘贴」
重复值看起来简单,但坑也不少。我记得有一次做产量预测,模型训练集和验证集之间居然有 30% 的重复样本——模型在验证集上表现「完美」,上线后直接崩了。
4.2.1 完全重复 vs 部分重复
- 完全重复:所有列的值都一样。直接用
df.drop_duplicates()干掉。 - 部分重复:只有某些关键列重复(比如时间戳+设备ID)。这时候需要业务判断——是正常的多条记录,还是数据采集出了问题?
# 完全重复删除
df_clean = df.drop_duplicates()
# 基于指定列判断重复
df_clean = df.drop_duplicates(subset=['timestamp', 'device_id'], keep='last')
避坑指南:我曾经在处理传感器数据时,发现大量「重复」记录。后来一查,原来是传感器每 5 秒采集一次,但某些时刻数据没变化,所以看起来重复了。这种情况下,保留一条就够了,但要在时间戳上做聚合。
4.3 异常值检测:IQR 与 Z-score 实战
异常值对模型的影响,比你想象的大得多。尤其是线性模型和神经网络,对异常值极其敏感。我常用的两种方法:IQR 和 Z-score。
4.3.1 IQR 方法:简单粗暴,适合偏态分布
IQR(四分位距)方法不假设数据服从正态分布,所以对偏态数据很友好。它的逻辑是:
- 计算 Q1(25% 分位数)和 Q3(75% 分位数)
- IQR = Q3 - Q1
- 正常范围:[Q1 - 1.5*IQR, Q3 + 1.5*IQR]
- 超出这个范围的就是异常值
def detect_outliers_iqr(df, column):
Q1 = df[column].quantile(0.25)
Q3 = df[column].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
outliers = df[(df[column] < lower_bound) | (df[column] > upper_bound)]
return outliers
# 使用示例
outliers_temp = detect_outliers_iqr(df, 'temperature')
print(f"检测到 {len(outliers_temp)} 个异常值")
注意:IQR 的 1.5 倍系数是经验值。对于极端敏感的场景(比如金融风控),我会把系数降到 1.0;对于容忍度高的场景(比如日志分析),可以升到 3.0。
4.3.2 Z-score 方法:适合正态分布数据
Z-score 假设数据服从正态分布,计算每个样本距离均值多少个标准差。通常 |Z| > 3 就认为是异常值。
from scipy import stats
import numpy as np
def detect_outliers_zscore(df, column, threshold=3):
z_scores = np.abs(stats.zscore(df[column]))
outliers = df[z_scores > threshold]
return outliers
# 使用示例
outliers_z = detect_outliers_zscore(df, 'pressure', threshold=3)
print(f"Z-score 检测到 {len(outliers_z)} 个异常值")
4.3.3 两种方法怎么选?
| 对比维度 | IQR 方法 | Z-score 方法 |
|---|---|---|
| 数据分布假设 | 无假设,适合偏态分布 | 假设正态分布 |
| 对极端值敏感度 | 较低,受极端值影响小 | 较高,极端值会拉偏均值和标准差 |
| 样本量要求 | 无严格要求 | 样本量 > 30 时效果较好 |
| 实际应用场景 | 产量数据、传感器数据(常有偏态) | 标准化后的特征、误差项分析 |
我个人在产量预测项目中,80% 的情况用 IQR。因为产量数据经常有长尾分布——比如某些设备故障导致产量骤降,用 Z-score 很容易被这些极端值带偏。
4.4 实战:完整的数据清洗流程
把上面三个步骤串起来,就是一个完整的数据清洗 pipeline。我一般这样写:
def data_cleaning_pipeline(df):
df_clean = df.copy()
# Step 1: 删除完全重复行
df_clean = df_clean.drop_duplicates()
# Step 2: 处理缺失值
# 数值型特征用中位数填充
num_cols = df_clean.select_dtypes(include=[np.number]).columns
for col in num_cols:
if df_clean[col].isnull().sum() / len(df_clean) < 0.5:
df_clean[col].fillna(df_clean[col].median(), inplace=True)
else:
df_clean.drop(columns=[col], inplace=True)
# 类别型特征用众数填充
cat_cols = df_clean.select_dtypes(include=['object']).columns
for col in cat_cols:
df_clean[col].fillna(df_clean[col].mode()[0], inplace=True)
# Step 3: 异常值处理(用 IQR 方法,替换为边界值)
for col in num_cols:
if col in df_clean.columns:
Q1 = df_clean[col].quantile(0.25)
Q3 = df_clean[col].quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - 1.5 * IQR
upper = Q3 + 1.5 * IQR
df_clean[col] = df_clean[col].clip(lower, upper)
return df_clean
# 执行清洗
df_cleaned = data_cleaning_pipeline(df)
print(f"清洗前样本数: {len(df)},清洗后样本数: {len(df_cleaned)}")
关键提醒:清洗流程一定要可复现。我习惯把清洗参数(填充值、异常值边界)保存下来,这样在模型上线后,对新数据的处理方式才能保持一致。