3、数据预处理(上):数据清洗、数据集成、数据变换
大家好,欢迎来到第三章。
说实话,数据预处理这章,是我最想跟你好好聊聊的部分。为什么?因为在我带过的项目里,80% 的坑都出在这一步。很多人急着调模型,结果数据一塌糊涂,跑出来的结果根本没法看。你想想看,垃圾进,垃圾出——这句话在数据挖掘圈子里,绝对是真理。
3.1 数据清洗:把脏东西筛出去
数据清洗,说白了就是给数据「洗澡」。我个人的习惯是,拿到任何数据集,第一件事不是看分布、不是画图,而是先检查三样东西:缺失值、重复值、异常值。这三兄弟不搞定,后面全是白干。
3.1.1 缺失值处理
缺失值太常见了。用户没填年龄、传感器某天没传数据、数据库字段为空……嗯,这里要注意:千万别直接删掉所有缺失行。我见过有人一上来就 dropna(),结果样本量直接砍半,模型直接废了。
怎么处理?分情况:
- 删除:缺失比例极小(比如 < 5%),且数据量够大,直接删行。
- 填充:数值型用均值、中位数;分类型用众数。我个人偏爱中位数,因为它不怕极端值干扰。
- 插值:时间序列数据,用前后值插值更合理。
- 模型预测:如果缺失值很重要,我会用其他特征训练一个简单模型来预测缺失值。
核心原则:缺失值处理没有银弹。你得先问自己——这个字段为什么缺失?是随机缺失,还是系统性问题?
举个代码例子,我常用的处理方式:
import pandas as pd
import numpy as np
# 读取数据
df = pd.read_csv('user_data.csv')
# 查看缺失情况
print(df.isnull().sum())
# 数值列:用中位数填充
df['age'].fillna(df['age'].median(), inplace=True)
# 分类型:用众数填充
df['gender'].fillna(df['gender'].mode()[0], inplace=True)
# 时间序列:向前填充
df['sensor_value'].fillna(method='ffill', inplace=True)
我的小技巧:填充后,建议加一列标记「该行是否曾被填充」。有时候这个标记本身就是一个有用的特征。
3.1.2 重复值处理
重复值看起来简单,但坑也不少。我记得有一次做电商用户画像,发现用户数比实际注册数多了 30%。查了半天,原来是数据采集脚本有 bug,每天重复拉取同一批用户。
重复值分两种:
- 完全重复:所有列都一样。直接
drop_duplicates()就行。 - 部分重复:只有关键字段重复(比如用户ID),但其他字段不同。这时候需要你判断——保留最新的?还是合并信息?
# 完全重复删除
df.drop_duplicates(inplace=True)
# 按用户ID去重,保留最后一条记录
df.drop_duplicates(subset='user_id', keep='last', inplace=True)
注意:不要盲目去重。有些业务场景下,重复数据本身可能代表多次操作。先搞清楚业务含义,再动手。
3.1.3 异常值处理
异常值,就是那些「明显不对劲」的数据。比如用户年龄 200 岁,或者日销售额突然变成负数。
怎么发现异常值?我常用的方法:
- 3σ 原则:数据服从正态分布时,超过均值 ± 3 倍标准差的值视为异常。
- 箱线图法:用 IQR(四分位距),超出 Q1 - 1.5×IQR 或 Q3 + 1.5×IQR 的值算异常。
- 业务规则:比如年龄不能超过 120,这是硬边界。
处理方式:
- 删除:异常值极少,且确认是录入错误。
- 截尾:把异常值替换成边界值(比如用 99% 分位数替换)。
- 单独建模:如果异常值本身有业务意义(比如欺诈交易),别删,单独分析。
# 箱线法检测异常值
Q1 = df['amount'].quantile(0.25)
Q3 = df['amount'].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
# 截尾处理
df['amount'] = df['amount'].clip(lower=lower_bound, upper=upper_bound)
避坑指南:我曾经在一个金融项目中,把高额交易当异常值删掉了。结果模型上线后,完全抓不住大额欺诈。后来才意识到——异常值不一定是「错误」,它可能是「信号」。
3.2 数据集成:把散落的数据拼起来
数据集成,就是把来自不同源头的数据合并到一起。比如用户信息在 MySQL,行为日志在 HDFS,你得把它们拼成一张大表。
这里最头疼的问题是什么?实体识别和冗余处理。
3.2.1 实体识别
同一个用户,在 A 系统叫「user_id=1001」,在 B 系统叫「customer_no=C1001」。你得想办法把它们对应起来。我常用的做法:
- 用唯一标识符(身份证号、手机号)做匹配。
- 没有唯一标识?用多个字段联合匹配(姓名+生日+地址)。
- 实在不行,用模糊匹配(比如编辑距离)。
3.2.2 冗余处理
两个表合并后,可能出现重复列。比如 A 表有「总金额」,B 表有「单价×数量」,其实算的是同一个东西。这时候保留一个就好。
# 合并两个 DataFrame
df_merged = pd.merge(df_users, df_orders, on='user_id', how='left')
# 检查冗余列
# 如果 'total_amount' 和 'price * quantity' 完全一致,删掉一个
df_merged.drop('total_amount', axis=1, inplace=True)
我的经验:数据集成时,一定要先做小规模测试。我见过有人直接全量合并,结果内存爆了,跑了一天的任务全废。
3.3 数据变换:让数据「听话」
数据变换,说白了就是给数据换个「长相」,让算法更容易理解。最常用的两个:标准化和归一化。
3.3.1 标准化(Standardization)
标准化是把数据变成均值为 0、标准差为 1 的分布。公式很简单:z = (x - μ) / σ。
什么时候用?当数据服从正态分布,或者算法假设数据是正态分布时(比如线性回归、SVM)。
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
df_scaled = scaler.fit_transform(df[['age', 'income']])
3.3.2 归一化(Normalization)
归一化是把数据缩放到 [0, 1] 区间。公式:x' = (x - min) / (max - min)。
什么时候用?当数据没有明显分布假设,或者算法基于距离计算时(比如 KNN、K-Means)。
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler()
df_normalized = scaler.fit_transform(df[['age', 'income']])
注意:标准化和归一化不是万能的。如果数据本身有异常值,归一化会把异常值压缩到 0 或 1,反而丢失信息。我建议先处理异常值,再做变换。
3.3.3 什么时候选哪个?
| 场景 | 推荐方法 | 原因 |
|---|---|---|
| 数据近似正态分布 | 标准化 | 保留分布形态 |
| 基于距离的算法 | 归一化 | 消除量纲影响 |
| 数据有界(如百分比) | 归一化 | 保持原始比例 |
| 数据有极端值 | 先处理异常值,再标准化 | 避免极端值主导 |
我的习惯:如果拿不准,我会两个都试一下,看哪个让模型收敛更快、效果更好。数据预处理没有标准答案,多试几次就知道了。
好了,这一章的内容就到这里。数据清洗、集成、变换,这三步做好了,后面的建模工作会顺畅很多。下一章我们继续聊数据预处理的另一半——特征编码、降维和特征选择。到时候见。