数据清洗往往不是最令人愉快的任务!然而,如果你问任何一位资深数据专业人士他们最有价值的技能是什么,许多人会告诉你:是他们将杂乱无章的数据整理成有用信息的能力。
本文涵盖了所有成功数据项目中必不可少的清洗技术。我还提供了实用的代码示例和一个样本数据集,方便你跟着操作,将这些方法应用到自己的工作中。
让我们开始吧!
注意:你可以使用这份样本混乱数据集跟随本文进行操作。
1. 处理缺失值
缺失数据或许是现实世界数据中最常见的问题。它可能导致分析结果偏差,使结论不可靠。因此,识别和处理缺失值是数据清洗的第一步。
识别缺失值
首先,需要量化并了解缺失数据的分布情况:
import pandas as pd
import numpy as np
# 载入样本数据集
df = pd.read_csv('messy_data.csv')
# 统计每列缺失值的数量和百分比
missing_count = df.isnull().sum()
missing_percentage = (df.isnull().sum() / len(df)) * 100
# 显示有缺失值的列,并按缺失数量降序排列
missing_data = pd.concat([missing_count, missing_percentage], axis=1,
keys=['Count', 'Percentage'])
print(missing_data[missing_data['Count'] > 0].sort_values('Count', ascending=False))
这段代码会加载数据集,统计每列缺失值数量及其占比,并仅显示含有缺失值的列,按缺失条目数排序。
删除策略
有时,当别无选择时,你可能需要删除含有缺失值的行或列。
-
删除完全为空的行:
df_clean = df.dropna(how='all')
-
删除关键列(如 name、email、age)存在缺失的行:
df_clean = df.dropna(subset=['name', 'email', 'age'])
-
删除缺失值超过50%的整列:
threshold = len(df) * 0.5 df_clean = df.dropna(axis=1, thresh=threshold)
填补缺失值(插补)
插补是用有意义的估计值替换缺失项。针对不同数据类型,插补策略也不同:
-
对于数值型数据(如 age),用中位数(受异常值影响较小);
-
对于收入(income),用平均值;
-
对于评价(customer_rating),用众数(最常见值);
-
对于类别型数据(如 gender),用众数或合理默认值。
df['age'] = df['age'].fillna(df['age'].median())
df['income'] = df['income'].fillna(df['income'].mean())
df['customer_rating'] = df['customer_rating'].fillna(df['customer_rating'].mode()[0])
df['gender'] = df['gender'].fillna(df['gender'].mode()[0])
df['comments'] = df['comments'].fillna('No comment provided')
更高级的方法是 KNN 插补,根据相似记录推测缺失值:
from sklearn.impute import KNNImputer
numeric_cols = ['age', 'income', 'customer_rating', 'purchase_amount']
imputer = KNNImputer(n_neighbors=3)
df[numeric_cols] = pd.DataFrame(
imputer.fit_transform(df[numeric_cols]),
columns=numeric_cols,
index=df.index
)
继续上文,以下是数据清洗指南的第2-4部分翻译:
2. 删除重复值
重复记录会导致统计结果失真,甚至得出错误结论。
首先,我们检查所有列的完全重复值:
# 查找完全重复的行
exact_duplicates = df.duplicated().sum()
print(f"Number of exact duplicate rows: {exact_duplicates}")
# 删除完全重复行
df_unique = df.drop_duplicates()
接着,寻找基于关键标识(如 name 和 email)的“功能性”重复:
# 查找可能的功能性重复(基于关键字段)
potential_duplicates = df.duplicated(subset=['name', 'email'], keep=False)
print(f"Number of potential functional duplicates: {potential_duplicates.sum()}")
print("Potential duplicate records:")
print(df[potential_duplicates].sort_values('name'))
对于重复项,我们保留信息最完整(缺失值最少)的记录:
# 先按非空字段数量排序,再去重
df['completeness'] = df.notna().sum(axis=1)
df_sorted = df.sort_values('completeness', ascending=False)
df_clean = df_sorted.drop_duplicates(subset=['name', 'email'])
df_clean = df_clean.drop(columns=['completeness'])
3. 标准化文本数据
文本不一致会制造不必要的“多样性”,增加分析难度。标准化文本字段能确保一致性。
首先,统一文本字段的大小写:姓名用标题格式,国家用大写,职位用小写。
# 统一类别字段的大小写
df['name'] = df['name'].str.title()
df['country'] = df['country'].str.upper()
df['job_title'] = df['job_title'].str.lower()
利用映射字典标准化国家名称和性别:
# 标准化国家名称
country_mapping = {
'US': 'USA',
'U.S.A.': 'USA',
'United States': 'USA',
'united states': 'USA',
'United states': 'USA'
}
df['country'] = df['country'].replace(country_mapping)
# 标准化性别取值
gender_mapping = {
'M': 'Male',
'm': 'Male',
'Male': 'Male',
'male': 'Male',
'F': 'Female',
'f': 'Female',
'Female': 'Female',
'female': 'Female'
}
df['gender'] = df['gender'].replace(gender_mapping)
最后,定义自定义函数,结合关键词标准化教育背景:
# 标准化学历信息
def standardize_education(edu_str):
if pd.isna(edu_str):
return np.nan
edu_str = str(edu_str).lower().strip()
if 'bachelor' in edu_str:
return "Bachelor's Degree"
elif 'master' in edu_str or 'mba' in edu_str or 'msc' in edu_str:
return "Master's Degree"
elif 'phd' in edu_str or 'doctor' in edu_str:
return "Doctorate"
else:
return "Other"
df['education'] = df['education'].apply(standardize_education)
4. 处理异常值
异常值可能极大影响统计与建模效果,正确识别和处理异常值至关重要。
首先,确保数值型字段为数字格式:
df['income'] = pd.to_numeric(df['income'], errors='coerce')
df['age'] = pd.to_numeric(df['age'], errors='coerce')
然后,采用两种方法检测异常值:Z-score(正态分布假设)和 IQR(对非正态数据更鲁棒)。
# 用 Z-score 方法检测异常值
from scipy import stats
z_scores = stats.zscore(df['income'].dropna())
outliers_z = (abs(z_scores) > 3)
print(f"Z-score method identified {outliers_z.sum()} outliers in income")
# IQR 方法检测异常值
Q1 = df['income'].quantile(0.25)
Q3 = df['income'].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
outliers_iqr = ((df['income'] < lower_bound) | (df['income'] > upper_bound))
print(f"IQR method identified {outliers_iqr.sum()} outliers in income")
# 可视化异常值
import matplotlib.pyplot as plt
plt.figure(figsize=(10, 6))
plt.boxplot(df['income'].dropna())
plt.title('Income Distribution with Outliers')
plt.ylabel('Income')
plt.show()
最后,修正明显错误值(如负收入),并用分位数法(如 winsorization)削弱极端值的影响:
# 修正不应为负的数值
df.loc[df['income'] < 0, 'income'] = np.nan # 替换为缺失值,后续处理
# 分位数法对极端值进行截断
def cap_outliers(series, lower_percentile=0.05, upper_percentile=0.95):
lower_limit = series.quantile(lower_percentile)
upper_limit = series.quantile(upper_percentile)
return series.clip(lower=lower_limit, upper=upper_limit)
df['income_capped'] = cap_outliers(df['income'], 0.01, 0.99)
继续上文,以下是数据清洗指南的第5-7部分翻译:
5. 数据类型转换
确保数据类型正确能提升性能,并保证各列能够进行合适的操作。
我们先检查当前的数据类型,然后将字符串格式的数值型数据转换为真正的数值类型,对于无法转换的内容以 NaN 处理:
# 显示当前数据类型
print(df.dtypes)
# 将字符串转换为数值型
df['age'] = pd.to_numeric(df['age'], errors='coerce')
df['income'] = pd.to_numeric(df['income'], errors='coerce')
df['customer_rating'] = pd.to_numeric(df['customer_rating'], errors='coerce')
df['purchase_amount'] = pd.to_numeric(df['purchase_amount'], errors='coerce')
对于日期字段,由于数据格式可能不一致,我们自定义函数尝试多种格式解析,解析成功后还可提取年份、月份等信息,并计算在职天数:
# 处理日期格式不一致
def parse_date(date_str):
if pd.isna(date_str):
return np.nan
for fmt in ['%Y-%m-%d', '%Y/%m/%d', '%m/%d/%Y']:
try:
return pd.to_datetime(date_str, format=fmt)
except:
continue
return pd.NaT
df['start_date'] = df['start_date'].apply(parse_date)
# 提取有用时间特征
df['start_year'] = df['start_date'].dt.year
df['start_month'] = df['start_date'].dt.month
df['tenure_days'] = (pd.Timestamp('now') - df['start_date']).dt.days
最后,统一手机号格式,先去除所有非数字字符,然后按统一格式输出:
# 统一手机号格式
def standardize_phone(phone):
if pd.isna(phone):
return np.nan
digits_only = ''.join(c for c in str(phone) if c.isdigit())
if len(digits_only) == 10:
return f"{digits_only[:3]}-{digits_only[3:6]}-{digits_only[6:]}"
else:
return digits_only # 非10位时原样返回
df['phone_number'] = df['phone_number'].apply(standardize_phone)
6. 处理分类变量的不一致
分类变量常常存在拼写不一致、大小写混乱、类别过多等问题。
首先,查看职位名称的分布,然后通过映射将相似职位统一:
# 查看职位名称的唯一值分布
print(f"Original job title count: {df['job_title'].nunique()}")
print(df['job_title'].value_counts())
# 标准化职位名称
job_mapping = {
'sr. developer': 'senior developer',
'senior developer': 'senior developer',
'ux designer': 'designer',
'regional manager': 'manager',
'project manager': 'manager',
'product manager': 'manager',
'lead engineer': 'senior developer',
'bi analyst': 'data analyst',
'data scientist': 'data analyst',
'hr specialist': 'specialist',
'marketing specialist': 'specialist'
}
df['standardized_job'] = df['job_title'].str.lower().replace(job_mapping)
接着,将部门字段归并到更宽泛的类别中:
# 部门归类
dept_categories = {
'IT': 'Technology',
'Engineering': 'Technology',
'Analytics': 'Technology',
'Design': 'Creative',
'Marketing': 'Business',
'Product': 'Business',
'Executive': 'Management',
'Human Resources': 'Operations',
'Management': 'Management'
}
df['dept_category'] = df['department'].replace(dept_categories)
最后,将出现频率较低的职位归为“Other”:
# 处理稀有类别,归入“Other”
value_counts = df['standardized_job'].value_counts()
threshold = 2 # 低于该次数归为Other
frequent_jobs = value_counts[value_counts >= threshold].index
df['job_grouped'] = df['standardized_job'].apply(
lambda x: x if x in frequent_jobs else 'Other'
)
7. 特征缩放与归一化
如果各特征取值范围差异很大,许多机器学习算法会受到影响,因此特征缩放很重要。
首先选出需要缩放的数值型字段,并填补缺失值:
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler
# 选取数值字段并填充缺失值
numeric_cols = ['age', 'income', 'customer_rating', 'purchase_amount']
numeric_df = df[numeric_cols].copy()
numeric_df = numeric_df.fillna(numeric_df.median())
然后分别用三种方法缩放:
-
标准化(StandardScaler):均值为0,标准差为1
scaler = StandardScaler() scaled_data = scaler.fit_transform(numeric_df) df_scaled = pd.DataFrame(scaled_data, columns=[f"{col}_scaled" for col in numeric_cols], index=df.index)
-
最小-最大缩放(MinMaxScaler):全部映射到[0,1]区间
min_max = MinMaxScaler() minmax_data = min_max.fit_transform(numeric_df) df_minmax = pd.DataFrame(minmax_data, columns=[f"{col}_minmax" for col in numeric_cols], index=df.index)
-
鲁棒缩放(RobustScaler):基于中位数和四分位数,对异常值不敏感
robust = RobustScaler() robust_data = robust.fit_transform(numeric_df) df_robust = pd.DataFrame(robust_data, columns=[f"{col}_robust" for col in numeric_cols], index=df.index)
将所有缩放结果与原始数据合并,并对比不同缩放效果:
df_with_scaled = pd.concat([df, df_scaled, df_minmax, df_robust], axis=1)
print(df_with_scaled[['income', 'income_scaled', 'income_minmax', 'income_robust']].head())
这样可以直观比较如“收入”(千位数)与“评分”(1-5分)等不同尺度特征在归一化后的表现。
继续上文,以下是数据清洗指南的第8-10部分翻译:
8. 字符串清洗与正则表达式
正则表达式对于清洗文本数据和从中提取信息非常有用。对于样本数据集(以及任何数据集),我们可以实现多种字符串清洗技巧。
首先,标准化姓名的大小写,并根据姓名提取“名”和“姓”:
import re
# 清理姓名:去除多余空格、标准化大小写
df['name_clean'] = df['name'].str.strip().str.title()
# 提取名和姓
def extract_names(full_name):
if pd.isna(full_name):
return pd.Series([np.nan, np.nan])
parts = full_name.strip().split()
if len(parts) >= 2:
return pd.Series([parts[0], parts[-1]])
else:
return pd.Series([parts[0] if parts else np.nan, np.nan])
name_parts = df['name_clean'].apply(extract_names)
df['first_name'] = name_parts[0]
df['last_name'] = name_parts[1]
对邮箱进行正则校验,并尝试修复常见问题:
# 校验和清洗邮箱地址
def clean_email(email):
if pd.isna(email):
return np.nan
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if re.match(pattern, email):
return email.lower()
else:
# 修复常见问题
if '@' not in email:
return np.nan
if not re.search(r'\.[a-zA-Z]{2,}$', email):
return email.lower() + '.com'
return email.lower()
df['email_clean'] = df['email'].apply(clean_email)
提取邮箱的域名信息:
# 提取邮箱域名
df['email_domain'] = df['email_clean'].str.extract(r'@([^@]+)$')
将不同格式的电话号码统一为一致的格式:
# 标准化/提取电话号码
def extract_phone(phone):
if pd.isna(phone):
return np.nan
digits = re.sub(r'\D', '', str(phone))
if len(digits) == 10:
return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
elif len(digits) > 10: # 包含国家码
return f"({digits[-10:-7]}) {digits[-7:-4]}-{digits[-4:]}"
else:
return np.nan
df['phone_standardized'] = df['phone_number'].apply(extract_phone)
这些技术让文本数据更一致,也能从非结构化或半结构化字段中提取结构化信息。
9. 脏数据中的特征工程
有时候,数据中的混乱本身也蕴含信息,你可以据此创造新的特征。
缺失值指示器:为关键字段创建“是否缺失”的哑变量
# 为关键字段创建缺失值指示器
missing_indicators = ['age', 'income', 'email', 'phone_number', 'comments']
for col in missing_indicators:
df[f'{col}_is_missing'] = df[col].isnull().astype(int)
数据质量评分:计算每条记录的完整性并分类
# 数据质量评分
df['quality_score'] = df.notna().sum(axis=1) / len(df.columns) * 10
df['quality_category'] = pd.cut(
df['quality_score'],
bins=[0, 6, 8, 10],
labels=['Poor', 'Average', 'Good']
)
标记可疑值,如整数倍收入、年龄不合理等:
# 检测潜在的数据录入错误
df['income_suspiciously_round'] = (df['income'] % 10000 == 0).astype(int)
df['age_out_of_range'] = ((df['age'] < 18) | (df['age'] > 80)).astype(int)
df['rating_out_of_range'] = ((df['customer_rating'] < 1) | (df['customer_rating'] > 5)).astype(int)
不仅要清洗脏数据,还应从中提取有用的信息。
10. 处理格式问题
不一致的格式会导致数据分析或建模时出现问题。
日期格式统一:
# 统一日期格式
df['start_date_clean'] = pd.to_datetime(df['start_date'], errors='coerce')
df['start_date_formatted'] = df['start_date_clean'].dt.strftime('%Y-%m-%d')
去除货币符号,方便数值转换:
# 处理货币格式问题
def clean_currency(amount):
if pd.isna(amount):
return np.nan
if isinstance(amount, (int, float)):
return amount
amount_str = str(amount)
amount_str = re.sub(r'[$,]', '', amount_str)
try:
return float(amount_str)
except:
return np.nan
df['purchase_amount_clean'] = df['purchase_amount'].apply(clean_currency)
统一数值格式,便于展示或报表:
# 统一数值显示格式
df['income_formatted'] = df['income'].apply(
lambda x: f"${x:,.2f}" if not pd.isna(x) else ""
)
对于要展示给最终用户或用于报表的数据,一致的格式尤为重要。
应用了多种清洗方法后,我们就得到了可以分析的最终干净数据集。
总结
我们已经覆盖了实际数据集中最常见数据质量问题的实用应对技巧。这些方法能帮助你将问题数据转化为可靠的数据资产。
数据清洗是一个迭代过程——每一步都能让你对数据的结构和质量有新的认识。上述模式可根据你的具体需求调整,并融入自动化工作流之中。
祝你数据清洗愉快!