3. 数据清洗:缺失值处理、异常值检测、数据去重

数据清洗这步,说白了就是给原始数据「洗澡」。脏数据进模型,结果一定脏。我见过太多团队在特征工程上花了大把时间,结果因为缺失值没处理好,模型上线就崩。嗯,咱们今天就把这块彻底讲透。

核心观点:数据清洗不是一次性工作,而是贯穿整个特征生产流水线的持续校验过程。

3.1 缺失值处理:别让「空」毁了你的模型

缺失值是最常见的数据质量问题。我个人习惯,拿到数据第一件事就是跑 df.isnull().sum(),看看哪些列有空洞。

3.1.1 缺失值类型与处理策略

缺失类型 含义 推荐处理方式
完全随机缺失 (MCAR) 缺失与任何变量无关 直接删除或简单填充
随机缺失 (MAR) 缺失与其他变量相关 模型预测填充或多重插补
非随机缺失 (MNAR) 缺失与自身值相关 需业务判断,谨慎处理

为什么区分这个?我在项目中遇到过,用户收入字段缺失往往是因为高收入人群不愿填写,这就是典型的 MNAR。如果直接填充均值,模型会严重低估高收入群体的特征。

3.1.2 常用填充方法

  • 删除法:缺失比例 < 5% 且样本充足时可用。但别盲目删,我见过有人删掉 30% 的数据,模型直接废了。
  • 统计填充:均值/中位数/众数。连续变量用中位数更稳健,离散变量用众数。
  • 前向/后向填充:时间序列数据常用。比如传感器每隔 5 秒上报一次,中间丢了几秒,用前值填充很合理。
  • 模型预测填充:用其他特征训练模型预测缺失值。KNN、随机森林都行,但计算量大。
# 我个人常用的缺失值处理流水线
import pandas as pd
from sklearn.impute import SimpleImputer

# 数值列:用中位数填充
num_imputer = SimpleImputer(strategy='median')
df['age'] = num_imputer.fit_transform(df[['age']])

# 类别列:用众数填充
cat_imputer = SimpleImputer(strategy='most_frequent')
df['gender'] = cat_imputer.fit_transform(df[['gender']])

# 时间序列:前向填充
df['sensor_value'] = df['sensor_value'].ffill()
小技巧:填充后建议加一列「是否缺失」的标识特征。有时缺失本身就是一个信号,比如用户没填手机号可能意味着活跃度低。

3.2 异常值检测:揪出数据里的「害群之马」

异常值不一定是错误,但必须被识别。我记得有一次做用户行为分析,发现某个用户的点击量是平均值的 1000 倍,结果是爬虫在刷数据。如果不处理,模型会学歪。

3.2.1 常用检测方法

方法 适用场景 优点 缺点
Z-Score 近似正态分布 简单快速 对极端值敏感
IQR 四分位距 偏态分布 稳健,不受极端值影响 阈值需人工设定
孤立森林 高维数据 线性时间复杂度 对局部异常不敏感
DBSCAN 聚类 密度不均匀数据 可发现任意形状异常 参数敏感
# 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_df = detect_outliers_iqr(df, 'price')
print(f"发现 {len(outliers_df)} 个异常值")
避坑指南:我曾经直接删掉所有异常值,结果模型在线上遇到真实极端情况时完全失效。正确的做法是:先标记异常值,分析其来源,再决定是修正、删除还是保留。

3.3 数据去重:别让重复样本「刷存在感」

重复数据会导致模型过拟合,还会让评估指标虚高。你想想看,如果训练集里同一个样本出现 10 次,模型就会拼命记住它,泛化能力自然差。

3.3.1 去重策略

  • 完全去重:所有字段完全一致。用 df.drop_duplicates() 搞定。
  • 部分去重:关键字段一致即可。比如用户 ID 相同但注册时间不同,保留最新的一条。
  • 模糊去重:文本相似度去重。比如「北京市朝阳区」和「北京朝阳区」其实是同一个地址。
# 完全去重
df_clean = df.drop_duplicates()

# 按用户 ID 去重,保留最新记录
df_dedup = df.sort_values('timestamp').drop_duplicates(subset='user_id', keep='last')

# 模糊去重(使用 recordlinkage 库)
import recordlinkage
indexer = recordlinkage.Index()
indexer.block('city')
pairs = indexer.index(df)

3.3.2 去重注意事项

嗯,这里要注意:去重前一定要确认「重复」的定义。我遇到过业务方说「用户 ID 重复就是重复」,结果发现同一个用户在不同设备上注册了多个账号,这些其实是独立用户。所以去重逻辑必须和业务对齐。

经验之谈:在离线流水线中,建议在数据接入层就做一次轻量去重,在特征计算层再做一次深度去重。两层防护,避免重复数据污染下游。

3.4 数据清洗流水线架构

下面这张图是我在实际项目中沉淀下来的清洗流水线,你可以直接复用。

数据清洗流水线架构图 数据清洗流水线架构 原始数据 缺失值处理 异常值检测 数据去重 清洗完成 注意顺序 先处理缺失值 再检测异常值 最后去重 质量监控 每一步记录统计 缺失率/异常率 重复率变化

3.5 实战:构建可复用的清洗模块

在实际的离线流水线中,我不会每次都手写清洗逻辑。我会封装成可配置的模块,通过 YAML 文件控制参数。

# config/cleaning_config.yaml
missing_value:
  strategy: "median"  # mean, median, mode, constant
  columns: ["age", "income", "score"]
  
outlier_detection:
  method: "iqr"  # zscore, iqr, isolation_forest
  threshold: 1.5
  action: "cap"  # remove, cap, mark
  
deduplication:
  subset: ["user_id", "event_id"]
  keep: "last"
  sort_by: "timestamp"
# 清洗模块核心代码
class DataCleaner:
    def __init__(self, config_path):
        self.config = self._load_config(config_path)
    
    def clean(self, df):
        df = self._handle_missing(df)
        df = self._detect_outliers(df)
        df = self._deduplicate(df)
        return df
    
    def _handle_missing(self, df):
        # 根据配置处理缺失值
        pass
    
    def _detect_outliers(self, df):
        # 根据配置检测异常值
        pass
    
    def _deduplicate(self, df):
        # 根据配置去重
        pass
核心原则:数据清洗模块必须可复现、可配置、可监控。每次清洗都要记录日志,包括处理了多少缺失值、删除了多少异常值、去重了多少条记录。这样出了问题才能追溯。

好了,数据清洗这块就讲到这里。记住,清洗不是一次性的,每次数据更新都要跑一遍。把清洗逻辑固化到流水线里,让机器替你做这些脏活累活。