打开一份数据,第一行是标题,第二行是空行,第三行的日期格式跟第四行不一样。这就是数据清洗的日常。
据 Kaggle 统计,数据科学家平均要把 60%-80% 的时间花在处理脏数据上。写模型?那只是最后 20% 的事。
这篇文章不讲理论,直接上代码。我会用一组真实的"脏数据"做例子,演示 5 个最常见的清洗场景。每个技巧都附完整代码,复制粘贴就能用。
先看看我们的脏数据长什么样
假设我们有一份用户行为日志,数据长这样:
user_id,username,signup_date,email,age,action
1,张三,2024-03-15,zhangsan@email.com,28,login
2,李四,2024/04/20,,35,purchase
3,,2024-05-10,lisi@test.com,,register
4,王五,2024.06.01,wangwu@email.com,abc,login
5,赵六,2024-07-22,zhao@test.com,42,Purchase
6,张三,2024-03-15,zhangsan@email.com,28,login
能看到的问题:日期格式不统一、有缺失值、存在重复数据、年龄字段混入了字母、操作行为大小写不一致。下面一个一个解决。
技巧一:加载数据并快速诊断
import pandas as pd
df = pd.read_csv('data.csv')
# 快速看数据健康状况
print(df.info())
print(df.describe(include='all'))
print(df.isnull().sum())
info() 告诉你每列的数据类型和非空数量。describe(include='all') 统计所有列的基本信息,包括字符串类型的。isnull().sum() 直接告诉你每列有多少空值。这三行是每次清洗数据前必跑的代码。
技巧二:去重
# 查看重复行数量
print(f"重复行数: {df.duplicated().sum()}")
# 按 user_id 去重,保留第一条
df = df.drop_duplicates(subset=['user_id'], keep='first')
print(f"去重后行数: {len(df)}")
duplicated() 返回一个布尔序列,标记哪些行是重复的。drop_duplicates() 可以按指定列去重。这里按 user_id 去重,因为同一个 ID 代表同一个人,重复就是脏数据。
实际项目中,你可能需要更复杂的去重逻辑——比如两行 user_id 相同但注册时间差超过一年,那可能是同一个用户注册了两次,需要合并而不是删除。
技巧三:处理缺失值
# 统计缺失值
print(df.isnull().sum())
# 对数值列:用中位数填充
df['age'] = df['age'].fillna(df['age'].median())
# 对字符串列:用众数填充
df['username'] = df['username'].fillna('unknown')
# 对邮箱:缺失的行直接标记
df['email_missing'] = df['email'].isnull()
处理缺失值没有固定公式,取决于数据背景和后续用途:
- 中位数填充:适合有极端值的数值列。平均数容易被 outliers 带偏,中位数更稳健。
- 众数填充:适合分类变量。
- 单独标记:如果缺失本身就有信息量(比如没有邮箱可能意味着用户没完善信息),就保留空值但额外加一个标记列。
- 删除:如果某列缺失率超过 70%,直接扔掉。留着只会增加噪声。
技巧四:日期格式化
# Pandas 自动推断日期格式
df['signup_date'] = pd.to_datetime(df['signup_date'], infer_datetime_format=True, errors='coerce')
# 提取有用的时间特征
df['signup_year'] = df['signup_date'].dt.year
df['signup_month'] = df['signup_date'].dt.month
df['signup_day'] = df['signup_date'].dt.weekday # 0=周一
pd.to_datetime() 的 infer_datetime_format=True 参数能自动处理 “2024-03-15”、“2024/04/20”、“2024.06.01” 等多种格式。遇到无法解析的值,errors='coerce' 会转成 NaT(Not a Time)。
提取时间特征是常见操作。注册月份可能影响用户留存分析,注册星期几可能反映周末和平日行为差异。这些衍生特征对后续建模很有价值。
技巧五:异常值检测与修正
# 年龄列里有 'abc',先转数值类型
df['age'] = pd.to_numeric(df['age'], errors='coerce')
# 用 IQR 方法检测异常值
Q1 = df['age'].quantile(0.25)
Q3 = df['age'].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
# 标记并处理异常值
df.loc[df['age'] < lower_bound, 'age'] = None
df.loc[df['age'] > upper_bound, 'age'] = None
# 处理后再用中位数填充缺失
df['age'] = df['age'].fillna(df['age'].median())
IQR 法用四分位距检测异常值,比简单设定阈值(比如 0-120)更灵活。它自动根据数据分布确定异常边界,适合不同量级的数据。
检测出异常值后,处理方式有三种:修正(如果有明确规则)、删除(如果数量极少)、保留但标记(如果异常本身有意义)。
技巧六:文本标准化
# 统一大小写
df['action'] = df['action'].str.lower()
# 去除首尾空格
df['username'] = df['username'].str.strip()
# 统一邮箱格式
df['email'] = df['email'].str.lower().str.strip()
# 查看标准化后的分布
print(df['action'].value_counts())
文本清洗看起来简单,但实际上最容易出问题。常见的坑包括:
- 隐藏的空格:用户输入的 “admin " 和 “admin” 看起来一样,但程序认为不同。
- 大小写不一致: “Login”、“login”、“LOGIN” 应该归为一类。
- 特殊字符:中文引号、全角空格、不可见字符等。
- 统一编码:如果数据来源混杂(Excel、CSV、数据库导出),编码问题可能导致乱码。
完整清洗流水线
把上面的步骤串起来,就是一个完整的清洗流程:
import pandas as pd
# 1. 加载数据
df = pd.read_csv('data.csv')
# 2. 去重
df = df.drop_duplicates(subset=['user_id'], keep='first')
# 3. 日期格式化
df['signup_date'] = pd.to_datetime(df['signup_date'], infer_datetime_format=True, errors='coerce')
df['signup_year'] = df['signup_date'].dt.year
df['signup_month'] = df['signup_date'].dt.month
# 4. 数值处理
df['age'] = pd.to_numeric(df['age'], errors='coerce')
Q1 = df['age'].quantile(0.25)
Q3 = df['age'].quantile(0.75)
IQR = Q3 - Q1
df.loc[(df['age'] < Q1 - 1.5 * IQR) | (df['age'] > Q3 + 1.5 * IQR), 'age'] = None
df['age'] = df['age'].fillna(df['age'].median())
# 5. 文本处理
df['action'] = df['action'].str.lower().str.strip()
df['email'] = df['email'].str.lower().str.strip()
# 6. 处理剩余缺失值
df['username'] = df['username'].fillna('unknown')
# 7. 保存干净数据
df.to_csv('clean_data.csv', index=False)
这段代码可以从一份脏数据生成一份可以直接用于分析的干净数据。保存一下,下次再来同样的数据直接跑就行。
结语
数据清洗没有银弹。每份数据的"脏"法都不一样,理解数据背后的来源才是关键。知道数据怎么来的,你就知道它可能脏在哪里。
建议每次清洗都写一个流水线脚本,不要手动在 notebook 里一行行敲。自动化清洗流程不仅能节省时间,还能确保每次处理结果一致。
你遇到过哪些奇葩的脏数据?来评论区分享一下吧。