4. 数据清洗实战(上):处理缺失值、重复值与异常值

数据清洗这步,说实话,是建模过程中最枯燥但又最关键的一环。我见过太多项目,前期EDA做得漂亮,特征工程也花里胡哨,结果模型上线后效果一塌糊涂——回头一查,数据里全是坑。今天咱们就把这三个最常见的坑填上:缺失值、重复值、异常值。

核心观点:数据清洗不是机械地删删补补,而是基于业务理解做决策。你清洗的方式,直接决定了模型的上限。

数据清洗实战(上)知识体系 数据清洗三大任务 缺失值处理 删除策略 填充策略 重复值处理 完全重复 vs 部分重复 异常值检测 IQR 方法 Z-score 方法 ⚠️ 核心原则 没有万能方法,一切基于数据分布和业务场景 清洗前先备份原始数据,清洗后做对比验证

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)}")

关键提醒:清洗流程一定要可复现。我习惯把清洗参数(填充值、异常值边界)保存下来,这样在模型上线后,对新数据的处理方式才能保持一致。