Python 数据清洗实战全解:从“脏数据”到“黄金数据”
摘要:在数据科学与机器学习的工程实践中,数据清洗(Data Cleaning)往往占据了项目周期的 60% 甚至 80%。虽然建模听起来更性感,但数据质量决定了模型的上限。如果输入的是“垃圾数据”,无论模型多么先进,输出的也只能是“垃圾结果”(Garbage In, Garbage Out)。
本文将不仅仅停留在基础操作,而是通过构建一个高度逼真的“脏乱差”数据集,深度剖析使用 Python(Pandas, NumPy)进行清洗的全流程。我们将探讨从基础的缺失值处理到高级的分组填充、从简单的去重到模糊匹配概念、从正则文本提取到逻辑一致性校验等各个维度的技巧。
环境准备与构建“脏”数据
为了让这篇教程具有高度的实操性,我们不依赖外部文件,而是直接用代码构建一个集成了各种典型“疑难杂症”的数据集。
我们将模拟一个电商平台的客户数据,其中包含以下典型问题:
缺失值 (Missing Values):NaN, None,模拟系统录入丢失。
重复录入 (Duplicates):完全重复行与部分关键信息重复。
数据类型错误 (Type Mismatch):数值型字段混入了字符(如货币符号、单位)。
时间序列混乱 (Inconsistent Dates):‘2023-01-01’ vs ‘01/02/2023’ vs ‘Not Date’。
文本噪音 (Text Noise):多余空格、大小写混乱、特殊字符。
异常值 (Outliers):年龄为负数、年龄超过人类极限。
非结构化数据 (Unstructured Data):电话号码格式千奇百怪。
1.1 导入库与生成深度脏数据
import pandas as pd
import numpy as np
import re # 正则表达式库
设置 Pandas 显示选项,防止列被折叠,确保看到所有“脏”细节
pd.set_option('display.max_columns', None)
pd.set_option('display.width', 1000)
构建模拟的脏数据
我们增加了一个 'Phone' 列来演示复杂的正则清洗
data = {
'OrderID': ['ORD-1001', 'ORD-1002', 'ORD-1003', 'ORD-1003', 'ORD-1005', 'ORD-1006', np.nan, 'ORD-1008', 'ORD-1009'],
'Customer_Name': [' Alice Smith ', 'Bob Jones', 'Charlie', 'Charlie', 'David', 'Eva', 'Frank', 'Grace', 'Henry_W'],
'Age': [25, 34, -5, -5, 120, 29, 45, np.nan, 200], # 包含负数、超大异常值、缺失值
'Signup_Date': ['2023-01-01', '01/02/2023', '2023.01.03', '2023.01.03', 'Not Date', '2023-01-06', '2023-01-07', '2023-01-08', '2023/01/09'],
'Product_Price': ['$100.50', '200.00', 'USD 150', 'USD 150', '300.5', '$50.00', '120', None, '1,000.00'], # 包含千分位、货币符号
'Email': ['[email protected]', 'bob@test', '[email protected]', '[email protected]', '[email protected]', 'eva#example.com', 'frank@web', '[email protected]', 'henry@mail'],
'Category': ['Electronics', 'Home', 'Electronics', 'Electronics', 'Unknown', 'home', 'Garden', 'Garden', 'Books'],
'Phone': ['123-456-7890', '(555) 123-4567', '123 456 7890', '123.456.7890', 'NaN', '9876543210', '111-222', '3334445555', 'N/A']
}
df = pd.DataFrame(data)
print("=== 原始脏数据预览 (Raw Data) ===")
print(df)
全面数据探索 (Comprehensive EDA)
在动手清洗之前,必须先进行“体检”。除了基础的 info(),我们还需要更深入地查看数据的分布和唯一性。
2.1 基础结构概览
print("\n=== 数据集基本信息 (Info) ===")
查看行数、列数、每列非空值数量及推断的数据类型
print(df.info())
print("\n=== 缺失值统计 ===")
计算每一列缺失值的总数,并计算占比,这决定了我们是删除还是填充
missing_count = df.isnull().sum()
missing_percent = (df.isnull().sum() / len(df)) * 100
missing_data = pd.concat([missing_count, missing_percent], axis=1, keys=['Total', 'Percent'])
print(missing_data)
2.2 统计分布与唯一值检查
对于数值型数据,查看其统计分布能迅速发现异常值;对于分类型数据,查看唯一值能发现拼写不一致的问题。
print("\n=== 描述性统计 (数值型异常) ===")
注意:由于 Price 目前是 Object 类型,这里只能看到 Age 的统计
我们可以看到 min 为 -5,max 为 200,这显然是不合理的
print(df.describe())
print("\n=== Category 列唯一值检查 ===")
检查是否同时存在 'Home' 和 'home'
print(df['Category'].unique())
初步诊断报告:
完整性:OrderID, Age, Product_Price 均存在缺失。
准确性:Age 存在逻辑错误的负值(-5)和不可能的极值(120, 200)。
一致性:Category 中混用了 'Home' 和 'home';日期格式极度不统一。
有效性:Phone 和 Price 包含大量非数字字符,无法直接使用。
处理重复数据 (Duplicate Management)
重复数据是统计分析的大忌,会导致求和虚高、平均值偏差。去重需要区分“完全重复”和“部分重复”。
3.1 完全重复行处理
print(f"\n清洗前总行数: {len(df)}")
-
检查完全重复的行(所有列值都相同)
duplicates = df[df.duplicated(keep=False)]
print("发现的完全重复行:")
print(duplicates) -
删除重复行
keep='first' 保留第一次出现的记录,删除后续重复的
df_cleaned = df.drop_duplicates(keep='first').reset_index(drop=True)
print(f"删除完全重复后行数: {len(df_cleaned)}")
3.2 基于主键的去重 (Subset Deduplication)
在实际业务中,有时候某条记录更新了部分字段(比如邮箱变了),但 OrderID 是一样的。这通常意味着数据采集时的重复录入。我们需要根据业务主键(Key)进行去重。
假设 OrderID 必须唯一,我们可以针对该列去重
这里我们再次检查,防止不同人用了同一个订单号
df_cleaned = df_cleaned.drop_duplicates(subset=['OrderID'], keep='first')
print(f"基于 OrderID 去重后行数: {len(df_cleaned)}")
缺失值处理策略 (Advanced Missing Values)
处理缺失值不仅仅是简单的 dropna 或 fillna,通过分析缺失值的成因,我们可以选择更智能的策略。
4.1 删除法 (Drop)
对于主键缺失的数据,通常意味着这是一条无法索引的垃圾数据。
删除 OrderID 为空的行,因为没有订单号无法关联其他表
df_cleaned = df_cleaned.dropna(subset=['OrderID'])
4.2 智能分组填充 (Imputation by Group)
对于 Age 缺失,简单用全局中位数填充(Global Median)比较粗糙。更好的方法是根据类别分组填充。例如,购买 'Electronics' 的人群可能比购买 'Garden' 的人群更年轻。
策略:计算每个 Category 的年龄中位数,用该类别的中位数去填充该类别下的缺失值。
-
预处理 Age:先将负数转正,排除极端值干扰中位数计算
df_cleaned['Age'] = df_cleaned['Age'].abs() -
分组填充逻辑
transform 返回一个与原索引对齐的序列
print("\n=== 执行分组填充前 Age 缺失情况 ===")
print(df_cleaned[df_cleaned['Age'].isnull()])
df_cleaned['Age'] = df_cleaned.groupby('Category')['Age'].transform(lambda x: x.fillna(x.median()))
print("=== 执行分组填充后 ===")
print(df_cleaned['Age'])
注:如果某一组全是 NaN,导致无法计算中位数,最后还得做一次全局填充兜底。
4.3 常数填充
对于价格或文本字段,如果缺失,往往需要填补默认值。
Price 暂时填 '0',稍后清洗格式时处理
df_cleaned['Product_Price'] = df_cleaned['Product_Price'].fillna('0')
深度数据类型转换 (Type Conversion & Formatting)
这是“脏数据”变“干净数据”最核心的重体力活。
5.1 货币字段清洗 (Complex String to Float)
Product_Price 不仅有货币符号,还有千分位分隔符(如 1,000.00),直接转会报错。
def clean_currency_advanced(x):
if isinstance(x, str):
1. 移除 'USD', '$', 以及空格
x = re.sub(r'[USD$\s]', '', x)
2. 移除千分位逗号 ',' (这步很重要,否则 '1,000' 会被 python 无法识别)
x = x.replace(',', '')
return x
应用清洗函数
df_cleaned['Product_Price'] = df_cleaned['Product_Price'].apply(clean_currency_advanced)
强制转换为浮点数
df_cleaned['Product_Price'] = pd.to_numeric(df_cleaned['Product_Price'], errors='coerce')
df_cleaned['Product_Price'] = df_cleaned['Product_Price'].fillna(0.0)
print("\n=== 清洗后的价格列 ===")
print(df_cleaned['Product_Price'])
5.2 智能日期解析 (Date Parsing)
处理混合格式日期('2023-01-01', '01/02/2023', '2023.01.03')。
pd.to_datetime 非常强大,能自动推断大多数格式
errors='coerce' 会将无法解析的 'Not Date' 变成 NaT (Not a Time)
df_cleaned['Signup_Date'] = pd.to_datetime(df_cleaned['Signup_Date'], errors='coerce')
print("\n=== 清洗后的日期列 ===")
print(df_cleaned['Signup_Date'])
填充无效日期:使用众数(出现最多的日期)或向前填充(ffill)
mode_date = df_cleaned['Signup_Date'].mode()[0]
df_cleaned['Signup_Date'] = df_cleaned['Signup_Date'].fillna(mode_date)
文本与字符串的高级处理
文本数据往往包含许多隐含信息,或者需要标准化。
6.1 字符串标准化
-
去除首尾空格
df_cleaned['Customer_Name'] = df_cleaned['Customer_Name'].str.strip() -
统一分类名称大小写 (解决 Home vs home)
df_cleaned['Category'] = df_cleaned['Category'].str.title() -
映射修正:将 Unknown 修正为 Other
df_cleaned['Category'] = df_cleaned['Category'].replace('Unknown', 'Other')
6.2 文本拆分 (Feature Engineering)
有时我们需要从一个字段中提取更多特征,比如把全名拆成 First Name 和 Last Name。
使用 str.split 扩展成两列
n=1 表示只拆分第一次出现的空格,expand=True 返回 DataFrame
name_split = df_cleaned['Customer_Name'].str.split(' ', n=1, expand=True)
df_cleaned['First_Name'] = name_split[0]
df_cleaned['Last_Name'] = name_split[1]
print("\n=== 拆分后的姓名特征 ===")
print(df_cleaned[['Customer_Name', 'First_Name', 'Last_Name']].head(3))
异常值检测:IQR 与 Z-Score
异常值(Outliers)会严重影响机器学习模型的性能(尤其是线性模型)。我们之前简单处理了负数,现在来处理极值。
7.1 方法一:IQR (四分位距法)
适用于非正态分布的数据,比较稳健。
Q1 = df_cleaned['Age'].quantile(0.25)
Q3 = df_cleaned['Age'].quantile(0.75)
IQR = Q3 - Q1
定义异常值边界
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
print(f"\n合理年龄范围 (IQR): {lower_bound:.2f} ~ {upper_bound:.2f}")
将超出范围的值截断 (Capping)
df_cleaned['Age_Capped'] = np.where(df_cleaned['Age'] > upper_bound, upper_bound,
np.where(df_cleaned['Age'] < lower_bound, lower_bound, df_cleaned['Age']))
7.2 方法二:Z-Score (标准分数法)
适用于近似正态分布的数据。如果 Z-Score 绝对值大于 3,通常视为异常。
from scipy import stats
计算 Z-score
df_cleaned['Age_Zscore'] = np.abs(stats.zscore(df_cleaned['Age']))
找出 Z-score > 3 的行
outliers_z = df_cleaned[df_cleaned['Age_Zscore'] > 3]
print("Z-Score 检测到的异常值:")
print(outliers_z[['Customer_Name', 'Age', 'Age_Zscore']])
复杂正则清洗:电话号码与邮箱
这是数据清洗中最考验正则功底的地方。我们的目标是将 (555) 123-4567 或 123.456.7890 统一为纯数字格式 1234567890。
8.1 电话号码清洗
def clean_phone(phone):
if pd.isna(phone) or phone == 'NaN':
return None
只保留数字
digits = re.sub(r'\D', '', str(phone))
简单的逻辑校验:假设有效号码必须是 10 位
if len(digits) == 10:
return digits
else:
return None # 格式不对视为缺失
df_cleaned['Phone_Cleaned'] = df_cleaned['Phone'].apply(clean_phone)
print("\n=== 电话号码清洗对比 ===")
print(df_cleaned[['Phone', 'Phone_Cleaned']])
8.2 邮箱合规性校验
email_pattern = r'[1]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$'
def validate_email(email):
if re.match(email_pattern, str(email)):
return email
else:
return "Invalid_Email_Format"
df_cleaned['Email_Cleaned'] = df_cleaned['Email'].apply(validate_email)
数据逻辑一致性检查 (Logical Consistency)
清洗的最后一步是检查数据在业务逻辑上是否自洽。
场景:如果 Age 小于 18 岁,但 Category 是 "Alcohol"(假设有这个类别),这就是逻辑错误。
本例场景:检查注册日期是否在当前日期之后(未来穿越者?)。
current_date = pd.Timestamp.now()
筛选出注册日期在未来的异常数据
future_signups = df_cleaned[df_cleaned['Signup_Date'] > current_date]
if not future_signups.empty:
print("警告:发现未来的注册日期!")
print(future_signups)
最终产出与总结
经过上述九九八十一难,我们的数据终于变得整洁、规范且可用。
移除中间产生的辅助列(如 Zscore, 分割出的 Name 等,根据需求保留)
cols_to_keep = ['OrderID', 'Customer_Name', 'Age_Capped', 'Signup_Date',
'Product_Price', 'Category', 'Phone_Cleaned', 'Email_Cleaned']
final_df = df_cleaned[cols_to_keep].copy()
重命名列以体现清洗结果
final_df.rename(columns={'Age_Capped': 'Age', 'Phone_Cleaned': 'Phone', 'Email_Cleaned': 'Email'}, inplace=True)
print("\n" + "="30)
print(" 最终黄金数据集 (FINAL GOLDEN DATASET) ")
print("="30)
print(final_df)
print("\n数据类型:")
print(final_df.dtypes)
数据清洗清单 (Checklist)
回顾整个过程,一个完善的清洗流水线应包含:
加载与备份:始终保留原始数据副本。
结构体检:利用 Info/Describe/Unique 快速定位问题。
去重:区分完全重复与主键重复。
缺失值:不要盲目删除,尝试分组填充或模型预测填充。
类型转换:清理非数字字符,处理千分位,统一日期格式。
文本清洗:去空格、大小写、正则提取、文本拆分。
异常值:利用 IQR 或 Z-Score 识别统计学异常值。
逻辑校验:结合业务规则(如年龄>0,结束时间>开始时间)进行终审。
掌握以上 Pandas 技巧,你就能处理 95% 的数据清洗任务,将原本枯燥的工作转化为高效的工程化流程。

浙公网安备 33010602011771号